Skip to main content

max / makenotwork

Move OTA updater signature onto the artifact Tauri signs each (target, arch) file independently, but the signature lived on ota_releases (one per app_id+version). Publishing a second platform for one version advertised it with the first platform's signature, and updater_check served that one signature for every target, so the Tauri updater silently refused the mismatched platform forever. The mnw-cli --release-id resume flow (attach a second artifact to an existing release) never sent a signature at all, making it reachable. Move the signature onto ota_artifacts, written at artifact-register time (one signature per file, which matches the CLI's one-artifact-per-invoke flow). updater_check now serves the artifact's own signature. create_release no longer carries a signature; the legacy ota_releases.signature column is left in place but unused. build_runner writes each artifact's own signature and refuses to publish if any successful target is unsigned. Migration 170 backfills each artifact from its parent release. Wire change threads through synckit-client (signature moves from ota_create_release to ota_register_artifact) and mnw-cli. Adds a regression test proving two platforms in one release each get their own signature, and a reject test for an unsigned artifact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 18:03 UTC
Commit: 0a7c05d9fcddb40a62d1122e89d94c2787faf51a
Parent: 7b4d16d
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 }"#;