max / makenotwork
8 files changed,
+280 insertions,
-107 deletions
| @@ -258,7 +258,7 @@ async fn publish(flags: &[String]) -> Result<()> { | |||
| 258 | 258 | None => { | |
| 259 | 259 | print!(" creating release v{}... ", args.version); | |
| 260 | 260 | let release = client | |
| 261 | - | .ota_create_release(&args.version, &args.notes, &args.signature) | |
| 261 | + | .ota_create_release(&args.version, &args.notes) | |
| 262 | 262 | .await | |
| 263 | 263 | .context("create release failed")?; | |
| 264 | 264 | println!("ok (release {})", release.id); | |
| @@ -268,7 +268,13 @@ async fn publish(flags: &[String]) -> Result<()> { | |||
| 268 | 268 | ||
| 269 | 269 | print!(" registering artifact... "); | |
| 270 | 270 | let upload = client | |
| 271 | - | .ota_register_artifact(release_id, &args.target, &args.arch, file_size) | |
| 271 | + | .ota_register_artifact( | |
| 272 | + | release_id, | |
| 273 | + | &args.target, | |
| 274 | + | &args.arch, | |
| 275 | + | file_size, | |
| 276 | + | &args.signature, | |
| 277 | + | ) | |
| 272 | 278 | .await | |
| 273 | 279 | .context("register artifact failed")?; | |
| 274 | 280 | println!("ok ({})", upload.s3_key); |
| @@ -0,0 +1,26 @@ | |||
| 1 | + | -- Move the Tauri updater signature from the release onto the artifact. | |
| 2 | + | -- | |
| 3 | + | -- Tauri's minisign signature is per-FILE: each (target, arch) artifact is signed | |
| 4 | + | -- independently and carries its own signature. ota_releases held a single | |
| 5 | + | -- release-level `signature` (migration 033), so publishing a second platform for | |
| 6 | + | -- one version advertised it with the first platform's signature, and | |
| 7 | + | -- updater_check served that one signature for every target. The Tauri updater | |
| 8 | + | -- then silently refuses the mismatched platform's update forever. mnw-cli's | |
| 9 | + | -- --release-id resume flow (attach a second artifact to an existing release) | |
| 10 | + | -- makes this reachable today. (audit 2026-07-21; wiki [[release-artifact-identity]].) | |
| 11 | + | -- | |
| 12 | + | -- Backfill each artifact from its parent release's signature. For a | |
| 13 | + | -- single-platform release this is exactly correct; for the buggy multi-platform | |
| 14 | + | -- case it preserves the current (wrong-for-the-second-platform) behavior rather | |
| 15 | + | -- than blanking it — re-publishing the affected artifact writes the right one. | |
| 16 | + | -- ota_releases.signature is left in place (forward-only migrations, live rows) | |
| 17 | + | -- but is no longer read or written; it is dead after this migration. | |
| 18 | + | ||
| 19 | + | ALTER TABLE ota_artifacts | |
| 20 | + | ADD COLUMN IF NOT EXISTS signature TEXT NOT NULL DEFAULT ''; | |
| 21 | + | ||
| 22 | + | UPDATE ota_artifacts a | |
| 23 | + | SET signature = r.signature | |
| 24 | + | FROM ota_releases r | |
| 25 | + | WHERE a.release_id = r.id | |
| 26 | + | AND a.signature = ''; |
| @@ -344,28 +344,24 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) { | |||
| 344 | 344 | return; | |
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | - | // Use signature from the first artifact that has one (release-level field). | |
| 348 | - | // A release with no signature can never be installed (Tauri refuses an | |
| 349 | - | // unsigned update); fail the build loudly rather than publishing a dead | |
| 350 | - | // release with an empty signature. | |
| 351 | - | let release_signature = match artifact_keys | |
| 352 | - | .iter() | |
| 353 | - | .find(|(_, _, _, sig)| !sig.is_empty()) | |
| 354 | - | .map(|(_, _, _, sig)| sig.as_str()) | |
| 347 | + | // Every artifact is signed independently and served with its own signature. | |
| 348 | + | // An unsigned artifact can never be installed (Tauri refuses an unsigned | |
| 349 | + | // update), so if any successful target lacks a signature, fail the build | |
| 350 | + | // loudly rather than publishing a release with a dead platform. | |
| 351 | + | if let Some((target_os, arch, _, _)) = | |
| 352 | + | artifact_keys.iter().find(|(_, _, _, sig)| sig.is_empty()) | |
| 355 | 353 | { | |
| 356 | - | Some(sig) => sig, | |
| 357 | - | None => { | |
| 358 | - | let msg = | |
| 359 | - | "build produced no signed artifact; refusing to publish an unsigned OTA release"; | |
| 360 | - | if let Err(e) = | |
| 361 | - | db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(msg)) | |
| 362 | - | .await | |
| 363 | - | { | |
| 364 | - | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build failed (missing signature)"); | |
| 365 | - | } | |
| 366 | - | return; | |
| 354 | + | let msg = format!( | |
| 355 | + | "build produced an unsigned artifact ({target_os}/{arch}); refusing to publish" | |
| 356 | + | ); | |
| 357 | + | if let Err(e) = | |
| 358 | + | db::builds::update_build_status(&ctx.db, build.id, BuildStatus::Failed, Some(&msg)) | |
| 359 | + | .await | |
| 360 | + | { | |
| 361 | + | tracing::error!(build_id = %build.id, error = ?e, "failed to mark build failed (missing signature)"); | |
| 367 | 362 | } | |
| 368 | - | }; | |
| 363 | + | return; | |
| 364 | + | } | |
| 369 | 365 | ||
| 370 | 366 | // Create OTA release (only for fully successful builds) | |
| 371 | 367 | let release = match db::ota::create_release( | |
| @@ -373,7 +369,6 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) { | |||
| 373 | 369 | build.app_id, | |
| 374 | 370 | &build.version, | |
| 375 | 371 | &format!("Automated build from tag {}", build.tag), | |
| 376 | - | release_signature, | |
| 377 | 372 | ) | |
| 378 | 373 | .await | |
| 379 | 374 | { | |
| @@ -400,7 +395,7 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) { | |||
| 400 | 395 | // Record artifacts and enqueue each for malware scanning. The artifact stays | |
| 401 | 396 | // `pending` (not served) until the scan clears it — same gate as the item | |
| 402 | 397 | // channel. | |
| 403 | - | for (target_os, arch, s3_key, _signature) in &artifact_keys { | |
| 398 | + | for (target_os, arch, s3_key, signature) in &artifact_keys { | |
| 404 | 399 | // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable) | |
| 405 | 400 | let file_size = if let Some(s3) = ctx.synckit_s3.as_ref() { | |
| 406 | 401 | s3.object_size(s3_key).await.ok().flatten().unwrap_or(0) | |
| @@ -408,8 +403,10 @@ async fn run_build(ctx: &BuildCtx, build: &DbBuild, config: &DbBuildConfig) { | |||
| 408 | 403 | 0 | |
| 409 | 404 | }; | |
| 410 | 405 | ||
| 411 | - | match db::ota::create_artifact(&ctx.db, release.id, target_os, arch, s3_key, file_size) | |
| 412 | - | .await | |
| 406 | + | match db::ota::create_artifact( | |
| 407 | + | &ctx.db, release.id, target_os, arch, s3_key, file_size, signature, | |
| 408 | + | ) | |
| 409 | + | .await | |
| 413 | 410 | { | |
| 414 | 411 | Ok(artifact) => { | |
| 415 | 412 | if let Some(owner_id) = owner_id |
| @@ -197,6 +197,10 @@ pub struct DbOtaArtifact { | |||
| 197 | 197 | pub arch: String, | |
| 198 | 198 | pub s3_key: String, | |
| 199 | 199 | pub file_size: i64, | |
| 200 | + | /// The artifact's own minisign signature. Tauri signs each (target, arch) | |
| 201 | + | /// file independently, so the signature is per-artifact, not per-release — | |
| 202 | + | /// `updater_check` serves this one for the requested platform. | |
| 203 | + | pub signature: String, | |
| 200 | 204 | /// Malware-scan gate: only `clean` artifacts are advertised/downloadable. | |
| 201 | 205 | pub scan_status: super::super::FileScanStatus, | |
| 202 | 206 | pub created_at: DateTime<Utc>, |
| @@ -37,6 +37,10 @@ pub async fn get_app_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbSyncA | |||
| 37 | 37 | ||
| 38 | 38 | /// Create a new OTA release for an app. | |
| 39 | 39 | /// | |
| 40 | + | /// The signature is per-artifact (Tauri signs each file independently), so it is | |
| 41 | + | /// supplied at artifact-register time via [`create_artifact`], not here. The | |
| 42 | + | /// legacy `ota_releases.signature` column is left defaulted and unused. | |
| 43 | + | /// | |
| 40 | 44 | /// Returns `Conflict` if a release with the same version already exists for | |
| 41 | 45 | /// this app (enforced by the UNIQUE(app_id, version) constraint in migration | |
| 42 | 46 | /// 033). | |
| @@ -46,20 +50,11 @@ pub async fn create_release( | |||
| 46 | 50 | app_id: SyncAppId, | |
| 47 | 51 | version: &str, | |
| 48 | 52 | notes: &str, | |
| 49 | - | signature: &str, | |
| 50 | 53 | ) -> Result<DbOtaRelease> { | |
| 51 | - | // A release with no signature can never be installed (the Tauri updater | |
| 52 | - | // silently refuses an unsigned update), and publishing one just advertises a | |
| 53 | - | // dead release. Reject it at the write boundary rather than storing "". | |
| 54 | - | if signature.trim().is_empty() { | |
| 55 | - | return Err(crate::error::AppError::BadRequest( | |
| 56 | - | "OTA release signature is required".to_string(), | |
| 57 | - | )); | |
| 58 | - | } | |
| 59 | 54 | let release = sqlx::query_as::<_, DbOtaRelease>( | |
| 60 | 55 | r#" | |
| 61 | - | INSERT INTO ota_releases (app_id, version, notes, signature) | |
| 62 | - | VALUES ($1, $2, $3, $4) | |
| 56 | + | INSERT INTO ota_releases (app_id, version, notes) | |
| 57 | + | VALUES ($1, $2, $3) | |
| 63 | 58 | ON CONFLICT (app_id, version) DO NOTHING | |
| 64 | 59 | RETURNING * | |
| 65 | 60 | "#, | |
| @@ -67,7 +62,6 @@ pub async fn create_release( | |||
| 67 | 62 | .bind(app_id) | |
| 68 | 63 | .bind(version) | |
| 69 | 64 | .bind(notes) | |
| 70 | - | .bind(signature) | |
| 71 | 65 | .fetch_optional(pool) | |
| 72 | 66 | .await?; | |
| 73 | 67 | ||
| @@ -186,6 +180,12 @@ pub async fn get_release_artifact_keys( | |||
| 186 | 180 | /// binary, retrying a half-finished upload) must succeed. Upsert on the | |
| 187 | 181 | /// `(release_id, target, arch)` unique key rather than raising 23505 -> 500 on the | |
| 188 | 182 | /// second upload (ultra-fuzz Run 12 Storage F1). | |
| 183 | + | /// | |
| 184 | + | /// `signature` is the artifact's own minisign signature and must be non-empty: an | |
| 185 | + | /// unsigned artifact can never be installed (the Tauri updater silently refuses | |
| 186 | + | /// it), so storing "" just advertises a dead download. Reject it at the write | |
| 187 | + | /// boundary. Re-upload preserves an existing signature when a new one isn't | |
| 188 | + | /// supplied, so a bytes-only retry doesn't blank it. | |
| 189 | 189 | #[tracing::instrument(skip_all)] | |
| 190 | 190 | pub async fn create_artifact( | |
| 191 | 191 | pool: &PgPool, | |
| @@ -194,14 +194,20 @@ pub async fn create_artifact( | |||
| 194 | 194 | arch: &str, | |
| 195 | 195 | s3_key: &str, | |
| 196 | 196 | file_size: i64, | |
| 197 | + | signature: &str, | |
| 197 | 198 | ) -> Result<DbOtaArtifact> { | |
| 199 | + | if signature.trim().is_empty() { | |
| 200 | + | return Err(crate::error::AppError::BadRequest( | |
| 201 | + | "OTA artifact signature is required".to_string(), | |
| 202 | + | )); | |
| 203 | + | } | |
| 198 | 204 | let artifact = sqlx::query_as::<_, DbOtaArtifact>( | |
| 199 | 205 | r#" | |
| 200 | - | INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size) | |
| 201 | - | VALUES ($1, $2, $3, $4, $5) | |
| 206 | + | INSERT INTO ota_artifacts (release_id, target, arch, s3_key, file_size, signature) | |
| 207 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 202 | 208 | ON CONFLICT (release_id, target, arch) | |
| 203 | 209 | DO UPDATE SET s3_key = EXCLUDED.s3_key, file_size = EXCLUDED.file_size, | |
| 204 | - | scan_status = 'pending' | |
| 210 | + | signature = EXCLUDED.signature, scan_status = 'pending' | |
| 205 | 211 | RETURNING * | |
| 206 | 212 | "#, | |
| 207 | 213 | ) | |
| @@ -210,6 +216,7 @@ pub async fn create_artifact( | |||
| 210 | 216 | .bind(arch) | |
| 211 | 217 | .bind(s3_key) | |
| 212 | 218 | .bind(file_size) | |
| 219 | + | .bind(signature) | |
| 213 | 220 | .fetch_one(pool) | |
| 214 | 221 | .await?; | |
| 215 | 222 |
| @@ -128,8 +128,6 @@ struct CreateReleaseRequest { | |||
| 128 | 128 | version: String, | |
| 129 | 129 | #[serde(default)] | |
| 130 | 130 | notes: String, | |
| 131 | - | #[serde(default)] | |
| 132 | - | signature: String, | |
| 133 | 131 | } | |
| 134 | 132 | ||
| 135 | 133 | #[derive(Serialize)] | |
| @@ -137,7 +135,6 @@ struct ReleaseResponse { | |||
| 137 | 135 | id: OtaReleaseId, | |
| 138 | 136 | version: String, | |
| 139 | 137 | notes: String, | |
| 140 | - | signature: String, | |
| 141 | 138 | pub_date: DateTime<Utc>, | |
| 142 | 139 | created_at: DateTime<Utc>, | |
| 143 | 140 | } | |
| @@ -148,7 +145,6 @@ impl From<db::DbOtaRelease> for ReleaseResponse { | |||
| 148 | 145 | id: r.id, | |
| 149 | 146 | version: r.version, | |
| 150 | 147 | notes: r.notes, | |
| 151 | - | signature: r.signature, | |
| 152 | 148 | pub_date: r.pub_date, | |
| 153 | 149 | created_at: r.created_at, | |
| 154 | 150 | } | |
| @@ -160,6 +156,10 @@ struct UploadArtifactRequest { | |||
| 160 | 156 | target: String, | |
| 161 | 157 | arch: String, | |
| 162 | 158 | file_size: i64, | |
| 159 | + | /// The artifact's minisign signature (per-file — Tauri signs each platform | |
| 160 | + | /// independently). Served verbatim to the updater for this target/arch. | |
| 161 | + | #[serde(default)] | |
| 162 | + | signature: String, | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | 165 | #[derive(Serialize)] | |
| @@ -211,19 +211,9 @@ async fn create_release( | |||
| 211 | 211 | verify_app_owner(&db, &sync_user, app_id).await?; | |
| 212 | 212 | validate_semver(&req.version)?; | |
| 213 | 213 | ||
| 214 | - | // The signature is served verbatim to the Tauri updater, which verifies the | |
| 215 | - | // artifact against it with minisign. An empty/implausible signature (the | |
| 216 | - | // serde default) produces a release the updater advertises but can never | |
| 217 | - | // install — reject it here rather than shipping an un-installable update. A | |
| 218 | - | // real base64-encoded minisign signature is well over 40 chars. | |
| 219 | - | let signature = req.signature.trim(); | |
| 220 | - | if signature.len() < 40 { | |
| 221 | - | return Err(AppError::BadRequest( | |
| 222 | - | "signature is required (base64-encoded minisign signature)".to_string(), | |
| 223 | - | )); | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | let release = db::ota::create_release(&db, app_id, &req.version, &req.notes, signature).await?; | |
| 214 | + | // The signature is per-artifact now (Tauri signs each platform's file | |
| 215 | + | // independently), so it is supplied when uploading each artifact, not here. | |
| 216 | + | let release = db::ota::create_release(&db, app_id, &req.version, &req.notes).await?; | |
| 227 | 217 | ||
| 228 | 218 | Ok(( | |
| 229 | 219 | axum::http::StatusCode::CREATED, | |
| @@ -304,6 +294,18 @@ async fn upload_artifact( | |||
| 304 | 294 | )); | |
| 305 | 295 | } | |
| 306 | 296 | ||
| 297 | + | // The signature is served verbatim to the Tauri updater, which verifies the | |
| 298 | + | // artifact against it with minisign. An empty/implausible signature produces | |
| 299 | + | // an artifact the updater advertises but can never install — reject it here | |
| 300 | + | // rather than shipping an un-installable update. A real base64-encoded | |
| 301 | + | // minisign signature is well over 40 chars. | |
| 302 | + | let signature = req.signature.trim(); | |
| 303 | + | if signature.len() < 40 { | |
| 304 | + | return Err(AppError::BadRequest( | |
| 305 | + | "signature is required (base64-encoded minisign signature)".to_string(), | |
| 306 | + | )); | |
| 307 | + | } | |
| 308 | + | ||
| 307 | 309 | // Verify the release belongs to this app (scoped lookup, not a full list scan). | |
| 308 | 310 | db::ota::get_release(&db, app_id, release_id) | |
| 309 | 311 | .await? | |
| @@ -338,6 +340,7 @@ async fn upload_artifact( | |||
| 338 | 340 | &req.arch, | |
| 339 | 341 | &s3_key, | |
| 340 | 342 | req.file_size, | |
| 343 | + | signature, | |
| 341 | 344 | ) | |
| 342 | 345 | .await?; | |
| 343 | 346 | ||
| @@ -491,7 +494,9 @@ async fn updater_check( | |||
| 491 | 494 | Ok(Json(TauriUpdaterResponse { | |
| 492 | 495 | version: latest.version, | |
| 493 | 496 | url: download_url, | |
| 494 | - | signature: latest.signature, | |
| 497 | + | // Per-artifact signature: this platform's file was signed independently, | |
| 498 | + | // so serve its own signature — not a shared release-level one. | |
| 499 | + | signature: artifact.signature, | |
| 495 | 500 | notes: latest.notes, | |
| 496 | 501 | pub_date: latest.pub_date.to_rfc3339(), | |
| 497 | 502 | }) |
| @@ -6,13 +6,18 @@ use serde::Deserialize; | |||
| 6 | 6 | use serde_json::json; | |
| 7 | 7 | use sqlx::PgPool; | |
| 8 | 8 | ||
| 9 | - | /// The release-create route rejects signatures shorter than 40 chars (a real | |
| 9 | + | /// The artifact-register route rejects signatures shorter than 40 chars (a real | |
| 10 | 10 | /// base64-encoded minisign signature is well over that), so any test that | |
| 11 | - | /// creates a release must supply a plausible-length one. Tests that assert the | |
| 11 | + | /// uploads an artifact must supply a plausible-length one. Tests that assert the | |
| 12 | 12 | /// *reject* path use their own short/empty values inline. | |
| 13 | 13 | const TEST_SIGNATURE: &str = | |
| 14 | 14 | "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZQoxYWJjZGVmZ2hpamtsbW5vcA=="; | |
| 15 | 15 | ||
| 16 | + | /// A second distinct signature, used to prove each artifact carries its own — | |
| 17 | + | /// the whole point of moving the signature onto `ota_artifacts`. | |
| 18 | + | const TEST_SIGNATURE_B: &str = | |
| 19 | + | "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHNpZ25hdHVyZSBCCjJ6eXh3dnV0c3JxcG9ubQ=="; | |
| 20 | + | ||
| 16 | 21 | // ── Response types ── | |
| 17 | 22 | ||
| 18 | 23 | #[derive(Deserialize)] | |
| @@ -301,8 +306,7 @@ async fn create_and_list_releases() { | |||
| 301 | 306 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 302 | 307 | &json!({ | |
| 303 | 308 | "version": "0.2.1", | |
| 304 | - | "notes": "Bug fixes", | |
| 305 | - | "signature": TEST_SIGNATURE | |
| 309 | + | "notes": "Bug fixes" | |
| 306 | 310 | }) | |
| 307 | 311 | .to_string(), | |
| 308 | 312 | ) | |
| @@ -332,7 +336,7 @@ async fn version_validation() { | |||
| 332 | 336 | .client | |
| 333 | 337 | .post_json( | |
| 334 | 338 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 335 | - | &json!({ "version": "not-semver", "notes": "", "signature": "" }).to_string(), | |
| 339 | + | &json!({ "version": "not-semver", "notes": "" }).to_string(), | |
| 336 | 340 | ) | |
| 337 | 341 | .await; | |
| 338 | 342 | assert_eq!(resp.status, 400, "Should reject non-semver"); | |
| @@ -342,7 +346,7 @@ async fn version_validation() { | |||
| 342 | 346 | .client | |
| 343 | 347 | .post_json( | |
| 344 | 348 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 345 | - | &json!({ "version": "1.2", "notes": "", "signature": "" }).to_string(), | |
| 349 | + | &json!({ "version": "1.2", "notes": "" }).to_string(), | |
| 346 | 350 | ) | |
| 347 | 351 | .await; | |
| 348 | 352 | assert_eq!(resp.status, 400, "Should reject incomplete semver"); | |
| @@ -358,8 +362,7 @@ async fn duplicate_version() { | |||
| 358 | 362 | .client | |
| 359 | 363 | .post_json( | |
| 360 | 364 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 361 | - | &json!({ "version": "1.0.0", "notes": "first", "signature": TEST_SIGNATURE }) | |
| 362 | - | .to_string(), | |
| 365 | + | &json!({ "version": "1.0.0", "notes": "first" }).to_string(), | |
| 363 | 366 | ) | |
| 364 | 367 | .await; | |
| 365 | 368 | assert_eq!(resp.status, 201); | |
| @@ -369,8 +372,7 @@ async fn duplicate_version() { | |||
| 369 | 372 | .client | |
| 370 | 373 | .post_json( | |
| 371 | 374 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 372 | - | &json!({ "version": "1.0.0", "notes": "duplicate", "signature": TEST_SIGNATURE }) | |
| 373 | - | .to_string(), | |
| 375 | + | &json!({ "version": "1.0.0", "notes": "duplicate" }).to_string(), | |
| 374 | 376 | ) | |
| 375 | 377 | .await; | |
| 376 | 378 | assert_eq!( | |
| @@ -392,8 +394,7 @@ async fn upload_artifact() { | |||
| 392 | 394 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 393 | 395 | &json!({ | |
| 394 | 396 | "version": "0.3.0", | |
| 395 | - | "notes": "New release", | |
| 396 | - | "signature": TEST_SIGNATURE | |
| 397 | + | "notes": "New release" | |
| 397 | 398 | }) | |
| 398 | 399 | .to_string(), | |
| 399 | 400 | ) | |
| @@ -412,7 +413,8 @@ async fn upload_artifact() { | |||
| 412 | 413 | &json!({ | |
| 413 | 414 | "target": "linux", | |
| 414 | 415 | "arch": "x86_64", | |
| 415 | - | "file_size": 12345678 | |
| 416 | + | "file_size": 12345678, | |
| 417 | + | "signature": TEST_SIGNATURE | |
| 416 | 418 | }) | |
| 417 | 419 | .to_string(), | |
| 418 | 420 | ) | |
| @@ -452,8 +454,7 @@ async fn updater_check_newer_version() { | |||
| 452 | 454 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 453 | 455 | &json!({ | |
| 454 | 456 | "version": "1.2.0", | |
| 455 | - | "notes": "Big update", | |
| 456 | - | "signature": TEST_SIGNATURE | |
| 457 | + | "notes": "Big update" | |
| 457 | 458 | }) | |
| 458 | 459 | .to_string(), | |
| 459 | 460 | ) | |
| @@ -469,7 +470,7 @@ async fn updater_check_newer_version() { | |||
| 469 | 470 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 470 | 471 | app_id, release.id | |
| 471 | 472 | ), | |
| 472 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 5000000 }).to_string(), | |
| 473 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 5000000, "signature": TEST_SIGNATURE }).to_string(), | |
| 473 | 474 | ) | |
| 474 | 475 | .await; | |
| 475 | 476 | assert_eq!(resp.status, 201); | |
| @@ -521,7 +522,7 @@ async fn updater_check_gated_until_artifact_clean() { | |||
| 521 | 522 | .client | |
| 522 | 523 | .post_json( | |
| 523 | 524 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 524 | - | &json!({ "version": "1.5.0", "notes": "", "signature": TEST_SIGNATURE }).to_string(), | |
| 525 | + | &json!({ "version": "1.5.0", "notes": "" }).to_string(), | |
| 525 | 526 | ) | |
| 526 | 527 | .await; | |
| 527 | 528 | assert_eq!(resp.status, 201); | |
| @@ -534,7 +535,7 @@ async fn updater_check_gated_until_artifact_clean() { | |||
| 534 | 535 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 535 | 536 | app_id, release.id | |
| 536 | 537 | ), | |
| 537 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), | |
| 538 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), | |
| 538 | 539 | ) | |
| 539 | 540 | .await; | |
| 540 | 541 | assert_eq!(resp.status, 201); | |
| @@ -587,7 +588,7 @@ async fn updater_check_no_update() { | |||
| 587 | 588 | .client | |
| 588 | 589 | .post_json( | |
| 589 | 590 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 590 | - | &json!({ "version": "1.0.0", "notes": "", "signature": TEST_SIGNATURE }).to_string(), | |
| 591 | + | &json!({ "version": "1.0.0", "notes": "" }).to_string(), | |
| 591 | 592 | ) | |
| 592 | 593 | .await; | |
| 593 | 594 | assert_eq!(resp.status, 201); | |
| @@ -600,7 +601,7 @@ async fn updater_check_no_update() { | |||
| 600 | 601 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 601 | 602 | app_id, release.id | |
| 602 | 603 | ), | |
| 603 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), | |
| 604 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), | |
| 604 | 605 | ) | |
| 605 | 606 | .await; | |
| 606 | 607 | assert_eq!(resp.status, 201); | |
| @@ -648,7 +649,7 @@ async fn updater_check_missing_platform() { | |||
| 648 | 649 | .client | |
| 649 | 650 | .post_json( | |
| 650 | 651 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 651 | - | &json!({ "version": "2.0.0", "notes": "", "signature": TEST_SIGNATURE }).to_string(), | |
| 652 | + | &json!({ "version": "2.0.0", "notes": "" }).to_string(), | |
| 652 | 653 | ) | |
| 653 | 654 | .await; | |
| 654 | 655 | assert_eq!(resp.status, 201); | |
| @@ -661,7 +662,7 @@ async fn updater_check_missing_platform() { | |||
| 661 | 662 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 662 | 663 | app_id, release.id | |
| 663 | 664 | ), | |
| 664 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), | |
| 665 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": TEST_SIGNATURE }).to_string(), | |
| 665 | 666 | ) | |
| 666 | 667 | .await; | |
| 667 | 668 | assert_eq!(resp.status, 201); | |
| @@ -678,6 +679,137 @@ async fn updater_check_missing_platform() { | |||
| 678 | 679 | ); | |
| 679 | 680 | } | |
| 680 | 681 | ||
| 682 | + | /// The artifact-register route rejects an empty/implausible signature: an | |
| 683 | + | /// unsigned artifact can never be installed (Tauri refuses it), so publishing one | |
| 684 | + | /// just advertises a dead download. | |
| 685 | + | #[tokio::test] | |
| 686 | + | async fn artifact_register_rejects_missing_signature() { | |
| 687 | + | let mut h = harness_with_synckit_storage().await; | |
| 688 | + | let (app_id, _) = setup_authenticated(&mut h).await; | |
| 689 | + | ||
| 690 | + | let resp = h | |
| 691 | + | .client | |
| 692 | + | .post_json( | |
| 693 | + | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 694 | + | &json!({ "version": "1.0.0", "notes": "" }).to_string(), | |
| 695 | + | ) | |
| 696 | + | .await; | |
| 697 | + | assert_eq!(resp.status, 201); | |
| 698 | + | let release: ReleaseResponse = resp.json(); | |
| 699 | + | ||
| 700 | + | // No signature field at all. | |
| 701 | + | let resp = h | |
| 702 | + | .client | |
| 703 | + | .post_json( | |
| 704 | + | &format!( | |
| 705 | + | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 706 | + | app_id, release.id | |
| 707 | + | ), | |
| 708 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000 }).to_string(), | |
| 709 | + | ) | |
| 710 | + | .await; | |
| 711 | + | assert_eq!(resp.status, 400, "missing signature must be rejected"); | |
| 712 | + | ||
| 713 | + | // Present but too short to be a real minisign signature. | |
| 714 | + | let resp = h | |
| 715 | + | .client | |
| 716 | + | .post_json( | |
| 717 | + | &format!( | |
| 718 | + | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 719 | + | app_id, release.id | |
| 720 | + | ), | |
| 721 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 1000, "signature": "short" }) | |
| 722 | + | .to_string(), | |
| 723 | + | ) | |
| 724 | + | .await; | |
| 725 | + | assert_eq!(resp.status, 400, "too-short signature must be rejected"); | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | /// Regression for the per-artifact signature bug (audit 2026-07-21): two | |
| 729 | + | /// platforms of one release each carry their OWN minisign signature, and the | |
| 730 | + | /// updater serves each platform its own — not a single shared release-level one. | |
| 731 | + | /// Before the fix, the second platform was advertised with the first's signature | |
| 732 | + | /// and the Tauri updater silently refused it. | |
| 733 | + | #[tokio::test] | |
| 734 | + | async fn updater_serves_per_artifact_signature() { | |
| 735 | + | let mut h = harness_with_synckit_storage().await; | |
| 736 | + | let user_id = h | |
| 737 | + | .signup("otaperart", "peart@example.com", "Password1!") | |
| 738 | + | .await; | |
| 739 | + | let (app_id, api_key) = create_sync_app_with_slug(&h.db, user_id, "perart").await; | |
| 740 | + | ||
| 741 | + | let resp = h | |
| 742 | + | .client | |
| 743 | + | .post_json( | |
| 744 | + | "/api/sync/auth", | |
| 745 | + | &json!({ | |
| 746 | + | "email": "peart@example.com", | |
| 747 | + | "password": "Password1!", | |
| 748 | + | "api_key": api_key, | |
| 749 | + | "key": "test-sdk-key", | |
| 750 | + | }) | |
| 751 | + | .to_string(), | |
| 752 | + | ) | |
| 753 | + | .await; | |
| 754 | + | let auth: AuthResponse = resp.json(); | |
| 755 | + | h.client.set_bearer_token(&auth.token); | |
| 756 | + | ||
| 757 | + | let resp = h | |
| 758 | + | .client | |
| 759 | + | .post_json( | |
| 760 | + | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 761 | + | &json!({ "version": "3.0.0", "notes": "" }).to_string(), | |
| 762 | + | ) | |
| 763 | + | .await; | |
| 764 | + | assert_eq!(resp.status, 201); | |
| 765 | + | let release: ReleaseResponse = resp.json(); | |
| 766 | + | ||
| 767 | + | // Two platforms, two distinct signatures. | |
| 768 | + | for (target, arch, sig) in [ | |
| 769 | + | ("linux", "x86_64", TEST_SIGNATURE), | |
| 770 | + | ("darwin", "aarch64", TEST_SIGNATURE_B), | |
| 771 | + | ] { | |
| 772 | + | let resp = h | |
| 773 | + | .client | |
| 774 | + | .post_json( | |
| 775 | + | &format!( | |
| 776 | + | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 777 | + | app_id, release.id | |
| 778 | + | ), | |
| 779 | + | &json!({ "target": target, "arch": arch, "file_size": 1000, "signature": sig }) | |
| 780 | + | .to_string(), | |
| 781 | + | ) | |
| 782 | + | .await; | |
| 783 | + | assert_eq!(resp.status, 201, "upload {target}/{arch}: {}", resp.text); | |
| 784 | + | } | |
| 785 | + | mark_artifacts_clean(&h.db, &release.id).await; | |
| 786 | + | ||
| 787 | + | h.client.clear_bearer_token(); | |
| 788 | + | ||
| 789 | + | // Each platform's updater check returns THAT platform's signature. | |
| 790 | + | let resp = h | |
| 791 | + | .client | |
| 792 | + | .get("/api/sync/ota/perart/linux/x86_64/1.0.0") | |
| 793 | + | .await; | |
| 794 | + | assert_eq!(resp.status, 200, "linux update: {}", resp.text); | |
| 795 | + | let update: TauriUpdaterResponse = resp.json(); | |
| 796 | + | assert_eq!( | |
| 797 | + | update.signature, TEST_SIGNATURE, | |
| 798 | + | "linux gets its own signature" | |
| 799 | + | ); | |
| 800 | + | ||
| 801 | + | let resp = h | |
| 802 | + | .client | |
| 803 | + | .get("/api/sync/ota/perart/darwin/aarch64/1.0.0") | |
| 804 | + | .await; | |
| 805 | + | assert_eq!(resp.status, 200, "darwin update: {}", resp.text); | |
| 806 | + | let update: TauriUpdaterResponse = resp.json(); | |
| 807 | + | assert_eq!( | |
| 808 | + | update.signature, TEST_SIGNATURE_B, | |
| 809 | + | "darwin gets its own signature, not linux's" | |
| 810 | + | ); | |
| 811 | + | } | |
| 812 | + | ||
| 681 | 813 | #[tokio::test] | |
| 682 | 814 | async fn artifact_download_redirect() { | |
| 683 | 815 | let mut h = harness_with_synckit_storage().await; | |
| @@ -704,7 +836,7 @@ async fn artifact_download_redirect() { | |||
| 704 | 836 | .client | |
| 705 | 837 | .post_json( | |
| 706 | 838 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 707 | - | &json!({ "version": "1.0.0", "notes": "", "signature": TEST_SIGNATURE }).to_string(), | |
| 839 | + | &json!({ "version": "1.0.0", "notes": "" }).to_string(), | |
| 708 | 840 | ) | |
| 709 | 841 | .await; | |
| 710 | 842 | assert_eq!(resp.status, 201); | |
| @@ -717,7 +849,7 @@ async fn artifact_download_redirect() { | |||
| 717 | 849 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 718 | 850 | app_id, release.id | |
| 719 | 851 | ), | |
| 720 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 999 }).to_string(), | |
| 852 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 999, "signature": TEST_SIGNATURE }).to_string(), | |
| 721 | 853 | ) | |
| 722 | 854 | .await; | |
| 723 | 855 | assert_eq!(resp.status, 201); | |
| @@ -758,7 +890,7 @@ async fn delete_release_cascades() { | |||
| 758 | 890 | .client | |
| 759 | 891 | .post_json( | |
| 760 | 892 | &format!("/api/sync/ota/apps/{}/releases", app_id), | |
| 761 | - | &json!({ "version": "1.0.0", "notes": "", "signature": TEST_SIGNATURE }).to_string(), | |
| 893 | + | &json!({ "version": "1.0.0", "notes": "" }).to_string(), | |
| 762 | 894 | ) | |
| 763 | 895 | .await; | |
| 764 | 896 | assert_eq!(resp.status, 201); | |
| @@ -771,7 +903,7 @@ async fn delete_release_cascades() { | |||
| 771 | 903 | "/api/sync/ota/apps/{}/releases/{}/artifacts", | |
| 772 | 904 | app_id, release.id | |
| 773 | 905 | ), | |
| 774 | - | &json!({ "target": "linux", "arch": "x86_64", "file_size": 500 }).to_string(), | |
| 906 | + | &json!({ "target": "linux", "arch": "x86_64", "file_size": 500, "signature": TEST_SIGNATURE }).to_string(), | |
| 775 | 907 | ) | |
| 776 | 908 | .await; | |
| 777 | 909 | assert_eq!(resp.status, 201); |
| @@ -30,7 +30,6 @@ pub struct OtaRelease { | |||
| 30 | 30 | pub id: Uuid, | |
| 31 | 31 | pub version: String, | |
| 32 | 32 | pub notes: String, | |
| 33 | - | pub signature: String, | |
| 34 | 33 | } | |
| 35 | 34 | ||
| 36 | 35 | /// A presigned upload target for an OTA artifact. | |
| @@ -59,7 +58,6 @@ pub struct OtaManifest { | |||
| 59 | 58 | struct CreateReleaseBody<'a> { | |
| 60 | 59 | version: &'a str, | |
| 61 | 60 | notes: &'a str, | |
| 62 | - | signature: &'a str, | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 65 | 63 | #[derive(Serialize)] | |
| @@ -67,30 +65,22 @@ struct RegisterArtifactBody<'a> { | |||
| 67 | 65 | target: &'a str, | |
| 68 | 66 | arch: &'a str, | |
| 69 | 67 | file_size: i64, | |
| 68 | + | signature: &'a str, | |
| 70 | 69 | } | |
| 71 | 70 | ||
| 72 | 71 | impl SyncKitClient { | |
| 73 | 72 | /// Create a new OTA release for the authenticated app. | |
| 74 | 73 | /// | |
| 75 | - | /// Pass an empty `signature` only for unsigned platforms — Tauri's updater | |
| 76 | - | /// silently refuses an update whose manifest signature is empty, so a real | |
| 77 | - | /// release must carry the minisign signature of the artifact. | |
| 78 | - | #[instrument(skip(self, signature))] | |
| 79 | - | pub async fn ota_create_release( | |
| 80 | - | &self, | |
| 81 | - | version: &str, | |
| 82 | - | notes: &str, | |
| 83 | - | signature: &str, | |
| 84 | - | ) -> Result<OtaRelease> { | |
| 74 | + | /// The signature is per-artifact (Tauri signs each platform's file | |
| 75 | + | /// independently), so it is supplied to [`Self::ota_register_artifact`], not | |
| 76 | + | /// here. | |
| 77 | + | #[instrument(skip(self))] | |
| 78 | + | pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result<OtaRelease> { | |
| 85 | 79 | let token = self.require_token()?; | |
| 86 | 80 | let (app_id, _user_id) = self.require_session_ids()?; | |
| 87 | 81 | let url = self.endpoints.ota_releases(app_id); | |
| 88 | 82 | ||
| 89 | - | let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { | |
| 90 | - | version, | |
| 91 | - | notes, | |
| 92 | - | signature, | |
| 93 | - | })?); | |
| 83 | + | let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?); | |
| 94 | 84 | ||
| 95 | 85 | self.retry_request_json( | |
| 96 | 86 | Idempotency::IdempotentWrite { | |
| @@ -112,14 +102,18 @@ impl SyncKitClient { | |||
| 112 | 102 | /// Register an artifact for a release and obtain a presigned upload URL. | |
| 113 | 103 | /// | |
| 114 | 104 | /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU | |
| 115 | - | /// (`x86_64`/`aarch64`), and `file_size` is the artifact size in bytes. | |
| 116 | - | #[instrument(skip(self))] | |
| 105 | + | /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and | |
| 106 | + | /// `signature` is this file's minisign signature — Tauri's updater silently | |
| 107 | + | /// refuses an update whose signature is empty or belongs to another platform, | |
| 108 | + | /// so each artifact must carry its own. | |
| 109 | + | #[instrument(skip(self, signature))] | |
| 117 | 110 | pub async fn ota_register_artifact( | |
| 118 | 111 | &self, | |
| 119 | 112 | release_id: Uuid, | |
| 120 | 113 | target: &str, | |
| 121 | 114 | arch: &str, | |
| 122 | 115 | file_size: i64, | |
| 116 | + | signature: &str, | |
| 123 | 117 | ) -> Result<OtaArtifactUpload> { | |
| 124 | 118 | let token = self.require_token()?; | |
| 125 | 119 | let (app_id, _user_id) = self.require_session_ids()?; | |
| @@ -129,6 +123,7 @@ impl SyncKitClient { | |||
| 129 | 123 | target, | |
| 130 | 124 | arch, | |
| 131 | 125 | file_size, | |
| 126 | + | signature, | |
| 132 | 127 | })?); | |
| 133 | 128 | ||
| 134 | 129 | self.retry_request_json( | |
| @@ -255,12 +250,12 @@ mod tests { | |||
| 255 | 250 | let body = CreateReleaseBody { | |
| 256 | 251 | version: "0.4.1", | |
| 257 | 252 | notes: "Bug fixes", | |
| 258 | - | signature: "RWS...==", | |
| 259 | 253 | }; | |
| 260 | 254 | let v: serde_json::Value = serde_json::to_value(&body).unwrap(); | |
| 261 | 255 | assert_eq!(v["version"], "0.4.1"); | |
| 262 | 256 | assert_eq!(v["notes"], "Bug fixes"); | |
| 263 | - | assert_eq!(v["signature"], "RWS...=="); | |
| 257 | + | // Signature is per-artifact now, not on the release. | |
| 258 | + | assert!(v.get("signature").is_none()); | |
| 264 | 259 | } | |
| 265 | 260 | ||
| 266 | 261 | #[test] | |
| @@ -269,11 +264,13 @@ mod tests { | |||
| 269 | 264 | target: "darwin", | |
| 270 | 265 | arch: "aarch64", | |
| 271 | 266 | file_size: 12_345, | |
| 267 | + | signature: "RWS...==", | |
| 272 | 268 | }; | |
| 273 | 269 | let v: serde_json::Value = serde_json::to_value(&body).unwrap(); | |
| 274 | 270 | assert_eq!(v["target"], "darwin"); | |
| 275 | 271 | assert_eq!(v["arch"], "aarch64"); | |
| 276 | 272 | assert_eq!(v["file_size"], 12_345); | |
| 273 | + | assert_eq!(v["signature"], "RWS...=="); | |
| 277 | 274 | } | |
| 278 | 275 | ||
| 279 | 276 | #[test] | |
| @@ -282,7 +279,6 @@ mod tests { | |||
| 282 | 279 | "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", | |
| 283 | 280 | "version": "0.4.1", | |
| 284 | 281 | "notes": "", | |
| 285 | - | "signature": "RWS=", | |
| 286 | 282 | "pub_date": "2026-06-07T00:00:00Z", | |
| 287 | 283 | "created_at": "2026-06-07T00:00:00Z" | |
| 288 | 284 | }"#; |