From 5149618672bebc5291b830704987dd8cf17d3dd7 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 11:58:24 +0200 Subject: [PATCH 1/2] Derive causal roundtrip from one operation Set roundtrip confirmed in exactly one place: the download acceptance of the same active operation, which already validated the request, attestation, authorization, response, keys, epochs, bindings, nonces, digests, payloads, and TTL for one explicit tuple. No new message type, endpoint, helper, bridge, or wire behavior is added. Cover the cross-operation replay property: a valid response artifact republished at a second operation's exact path fails chain validation and ends as conflict without download or roundtrip evidence. --- .../DiagnosticsPairingController.swift | 17 +++- .../Views/ControlledDiagnosticsView.swift | 11 ++- ios/VaultSync/de.lproj/Localizable.strings | 5 +- ios/VaultSync/en.lproj/Localizable.strings | 5 +- ios/VaultSync/es.lproj/Localizable.strings | 5 +- .../zh-Hans.lproj/Localizable.strings | 5 +- ...osticsControlledDownloadRuntimeTests.swift | 4 + ...gnosticsForegroundUploadRuntimeTests.swift | 84 ++++++++++++++++++- 8 files changed, 120 insertions(+), 16 deletions(-) diff --git a/ios/VaultSync/Services/DiagnosticsPairingController.swift b/ios/VaultSync/Services/DiagnosticsPairingController.swift index efc27f8..df882fe 100644 --- a/ios/VaultSync/Services/DiagnosticsPairingController.swift +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -29,6 +29,7 @@ final class DiagnosticsPairingController { case checking case uploadObserved case downloadObserved + case roundtripConfirmed case cancelled case timedOut case interrupted @@ -41,7 +42,7 @@ final class DiagnosticsPairingController { struct UploadEvidence: Equatable, Sendable { var uploadObserved = false var downloadObserved = false - let roundtripConfirmed = false + var roundtripConfirmed = false } struct UploadStatus: Equatable, Sendable { @@ -801,9 +802,19 @@ final class DiagnosticsPairingController { // not a pinned-channel protocol mismatch. throw DiagnosticsProtocolError.conflict } + // D024 step 10: this exact acceptance already validated the + // request, attestation, authorization, keys, epochs, bindings, + // and TTL for the one active operation, so the causal roundtrip + // derives from exactly this upload-then-download chain and from + // nothing else. It is a scoped propagation claim, never global + // sync health or future-delivery evidence. uploadStatuses[recordID] = UploadStatus( - phase: .downloadObserved, - evidence: UploadEvidence(uploadObserved: true, downloadObserved: true), + phase: .roundtripConfirmed, + evidence: UploadEvidence( + uploadObserved: true, + downloadObserved: true, + roundtripConfirmed: true + ), completedPolls: completedUploadPolls, completedResponsePolls: index + 1 ) diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift index b5fd11d..0eba73a 100644 --- a/ios/VaultSync/Views/ControlledDiagnosticsView.swift +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -91,7 +91,7 @@ struct ControlledDiagnosticsView: View { startPendingUpload() } } message: { - Text(L10n.tr("VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones.")) + Text(L10n.tr("VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones.")) } } @@ -100,7 +100,7 @@ struct ControlledDiagnosticsView: View { Label(L10n.tr("Explicit local or VPN pairing only"), systemImage: "lock.shield") Label(L10n.tr("TLS 1.3 with an exact QR-pinned key"), systemImage: "checkmark.seal") Label(L10n.tr("No discovery, trust adoption, Relay tunnel, or automatic namespace"), systemImage: "hand.raised") - Text(L10n.tr("Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones.")) + Text(L10n.tr("Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones.")) .font(.caption) .foregroundStyle(.secondary) } header: { @@ -561,6 +561,8 @@ struct ControlledDiagnosticsView: View { ) case .downloadObserved: return L10n.tr("Upload and download observed — roundtrip remains unobserved") + case .roundtripConfirmed: + return L10n.tr("Causal roundtrip confirmed for this one operation — not global sync health") default: return L10n.tr("Partial: upload observed, download unobserved — no late result can upgrade it") } @@ -572,6 +574,8 @@ struct ControlledDiagnosticsView: View { return L10n.fmt("Upload pending after %d of 8 exact polls", status.completedPolls) case .uploadObserved, .downloadObserved: return L10n.tr("Upload and download observed — roundtrip remains unobserved") + case .roundtripConfirmed: + return L10n.tr("Causal roundtrip confirmed for this one operation — not global sync health") case .cancelled: return L10n.tr("Upload check cancelled — no late result can upgrade it") case .timedOut: @@ -593,6 +597,7 @@ struct ControlledDiagnosticsView: View { switch phase { case .uploadObserved: return "arrow.up.circle.fill" case .downloadObserved: return "arrow.down.circle.fill" + case .roundtripConfirmed: return "arrow.triangle.2.circlepath.circle.fill" case .preflighting, .checking: return "hourglass" case .cancelled, .timedOut, .interrupted, .unavailable: return "exclamationmark.circle" case .conflict, .rateLimited, .unsupported: return "xmark.shield" @@ -601,7 +606,7 @@ struct ControlledDiagnosticsView: View { private func uploadStatusColor(_ phase: DiagnosticsPairingController.UploadPhase) -> Color { switch phase { - case .uploadObserved, .downloadObserved: return Color.statusSuccess + case .uploadObserved, .downloadObserved, .roundtripConfirmed: return Color.statusSuccess case .preflighting, .checking: return Color.statusAttention case .cancelled, .timedOut, .interrupted, .unavailable: return Color.statusAttention case .conflict, .rateLimited, .unsupported: return Color.statusError diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 42d4f72..2460d0a 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -916,8 +916,9 @@ "Retry Exact Pairing Cancellation" = "Exakten Pairing-Abbruch wiederholen"; "Start controlled upload and download check?" = "Kontrollierte Upload- und Download-Prüfung starten?"; "Start Upload and Download Check" = "Upload- und Download-Prüfung starten"; -"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync erstellt genau eine signierte Anfrage mit 256 Zufallsbytes im bereits autorisierten Diagnose-Namespace und scannt nur den ausgewählten Ordner neu. Nur eine exakt gebundene signierte Antwort des gepinnten Helpers kann den Upload als beobachtet markieren. Nach einem akzeptierten Upload autorisiert VaultSync genau eine signierte Helper-Antwortdatei mit 256 Zufallsbytes im selben Namespace; nur deren frisches synchronisiertes Eintreffen mit vollständiger Validierung kann den Download als beobachtet markieren. Der Roundtrip bleibt unbeobachtet. Undurchsichtige Kopien können auf Peers, in Backups, Versionen, Konflikten oder Tombstones verbleiben."; -"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing und Capability-Prüfungen erzeugen keine Upload-, Download- oder Roundtrip-Evidence. Nur eine separate ausdrückliche Prüfung kann den Upload und danach den Download als beobachtet markieren; der Roundtrip bleibt unabhängig. Der Diagnose-Namespace ist für synchronisierte Peers sichtbar und kann in Backups, Versionen, Konfliktkopien und Tombstones verbleiben."; +"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync erstellt genau eine signierte Anfrage mit 256 Zufallsbytes im bereits autorisierten Diagnose-Namespace und scannt nur den ausgewählten Ordner neu. Nur eine exakt gebundene signierte Antwort des gepinnten Helpers kann den Upload als beobachtet markieren. Nach einem akzeptierten Upload autorisiert VaultSync genau eine signierte Helper-Antwortdatei mit 256 Zufallsbytes im selben Namespace; nur deren frisches synchronisiertes Eintreffen mit vollständiger Validierung kann den Download als beobachtet markieren. Ein kausaler Roundtrip wird nur aus Upload und anschließendem Download derselben Operation bestätigt und ist niemals globale Sync-Gesundheit. Undurchsichtige Kopien können auf Peers, in Backups, Versionen, Konflikten oder Tombstones verbleiben."; +"Causal roundtrip confirmed for this one operation — not global sync health" = "Kausaler Roundtrip für diese eine Operation bestätigt — keine globale Sync-Gesundheit"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing und Capability-Prüfungen erzeugen keine Upload-, Download- oder Roundtrip-Evidence. Nur eine separate ausdrückliche Prüfung kann den Upload und danach den Download als beobachtet markieren und den kausalen Roundtrip allein aus dieser einen Operation ableiten. Der Diagnose-Namespace ist für synchronisierte Peers sichtbar und kann in Backups, Versionen, Konfliktkopien und Tombstones verbleiben."; "Upload target: %@ · designated peer: %@" = "Upload-Ziel: %@ · designierter Peer: %@"; "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "Upload, Download und Roundtrip sind getrennte Evidence-Felder. Cleanup wertet kein Feld auf."; "Cancel Controlled Check" = "Kontrollierte Prüfung abbrechen"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 76cdd46..b544e72 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -916,8 +916,9 @@ "Retry Exact Pairing Cancellation" = "Retry Exact Pairing Cancellation"; "Start controlled upload and download check?" = "Start controlled upload and download check?"; "Start Upload and Download Check" = "Start Upload and Download Check"; -"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones."; -"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones."; +"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones."; +"Causal roundtrip confirmed for this one operation — not global sync health" = "Causal roundtrip confirmed for this one operation — not global sync health"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones."; "Upload target: %@ · designated peer: %@" = "Upload target: %@ · designated peer: %@"; "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field."; "Cancel Controlled Check" = "Cancel Controlled Check"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 9fbd784..274454a 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -916,8 +916,9 @@ "Retry Exact Pairing Cancellation" = "Reintentar cancelación exacta del emparejamiento"; "Start controlled upload and download check?" = "¿Iniciar la comprobación controlada de carga y descarga?"; "Start Upload and Download Check" = "Iniciar comprobación de carga y descarga"; -"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync creará una única solicitud firmada con 256 bytes aleatorios en el espacio de nombres de diagnóstico ya autorizado y volverá a escanear solo la carpeta seleccionada. Solo una respuesta firmada y vinculada exactamente del helper fijado puede marcar la carga como observada. Tras una carga aceptada, VaultSync autoriza exactamente un archivo de respuesta firmado del helper con 256 bytes aleatorios en el mismo espacio de nombres; solo su llegada sincronizada y reciente con validación completa puede marcar la descarga como observada. El viaje de ida y vuelta permanece sin observar. Pueden quedar copias opacas en pares, copias de seguridad, versiones, conflictos o registros de eliminación."; -"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "El emparejamiento y las comprobaciones de capacidad no crean evidencia de carga, descarga ni viaje de ida y vuelta. Solo una comprobación explícita y separada puede marcar la carga como observada y, después de ella, la descarga; el viaje de ida y vuelta sigue siendo independiente. El espacio de nombres de diagnóstico es visible para los pares sincronizados y puede permanecer en copias de seguridad, versiones, copias en conflicto y registros de eliminación."; +"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync creará una única solicitud firmada con 256 bytes aleatorios en el espacio de nombres de diagnóstico ya autorizado y volverá a escanear solo la carpeta seleccionada. Solo una respuesta firmada y vinculada exactamente del helper fijado puede marcar la carga como observada. Tras una carga aceptada, VaultSync autoriza exactamente un archivo de respuesta firmado del helper con 256 bytes aleatorios en el mismo espacio de nombres; solo su llegada sincronizada y reciente con validación completa puede marcar la descarga como observada. Un viaje de ida y vuelta causal se confirma solo a partir de la carga y la posterior descarga de la misma operación y nunca es salud global de sincronización. Pueden quedar copias opacas en pares, copias de seguridad, versiones, conflictos o registros de eliminación."; +"Causal roundtrip confirmed for this one operation — not global sync health" = "Viaje de ida y vuelta causal confirmado para esta única operación — no es salud global de sincronización"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "El emparejamiento y las comprobaciones de capacidad no crean evidencia de carga, descarga ni viaje de ida y vuelta. Solo una comprobación explícita y separada puede marcar la carga como observada, después la descarga, y derivar el viaje de ida y vuelta causal únicamente de esa operación. El espacio de nombres de diagnóstico es visible para los pares sincronizados y puede permanecer en copias de seguridad, versiones, copias en conflicto y registros de eliminación."; "Upload target: %@ · designated peer: %@" = "Destino de carga: %@ · par designado: %@"; "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "La carga, la descarga y el viaje de ida y vuelta son campos de evidencia separados. La limpieza nunca mejora ningún campo."; "Cancel Controlled Check" = "Cancelar comprobación controlada"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 00043d1..5677089 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -916,8 +916,9 @@ "Retry Exact Pairing Cancellation" = "重试原配对取消请求"; "Start controlled upload and download check?" = "开始受控上传和下载检查?"; "Start Upload and Download Check" = "开始上传和下载检查"; -"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. Roundtrip remains unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync 将在已授权的诊断命名空间中创建一个包含 256 个随机字节的签名请求,并且只重新扫描所选文件夹。只有来自已固定 helper、精确绑定且签名有效的回复才能将上传标记为已观察。在上传被接受后,VaultSync 会在同一命名空间中授权 helper 创建且仅创建一个包含 256 个随机字节的签名响应文件;只有其新近的同步到达并通过完整验证,才能将下载标记为已观察。往返仍为未观察。不透明副本可能保留在对等设备、备份、版本、冲突副本或删除记录中。"; -"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed and, after it, download observed; roundtrip remains independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "配对和能力检查不会产生上传、下载或往返证据。只有单独且明确启动的检查才能将上传标记为已观察,并在其后将下载标记为已观察;往返始终独立。诊断命名空间对同步的对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; +"VaultSync will create one signed request with 256 random bytes in the already authorized diagnostics namespace and rescan only the selected folder. Only an exact signed reply from the pinned helper can mark upload observed. After an accepted upload, VaultSync authorizes exactly one signed helper response file with 256 random bytes in the same namespace; only its fresh synchronized arrival with full validation can mark download observed. A causal roundtrip is confirmed only from the same operation's upload then download and is never global sync health. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync 将在已授权的诊断命名空间中创建一个包含 256 个随机字节的签名请求,并且只重新扫描所选文件夹。只有来自已固定 helper、精确绑定且签名有效的回复才能将上传标记为已观察。在上传被接受后,VaultSync 会在同一命名空间中授权 helper 创建且仅创建一个包含 256 个随机字节的签名响应文件;只有其新近的同步到达并通过完整验证,才能将下载标记为已观察。因果往返只能由同一操作的上传及其后的下载确认,绝不代表全局同步健康。不透明副本可能保留在对等设备、备份、版本、冲突副本或删除记录中。"; +"Causal roundtrip confirmed for this one operation — not global sync health" = "已确认这一次操作的因果往返 — 不代表全局同步健康"; +"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit check may mark upload observed, then download observed, and derive the causal roundtrip from that one operation alone. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "配对和能力检查不会产生上传、下载或往返证据。只有单独且明确启动的检查才能将上传标记为已观察,随后将下载标记为已观察,并仅从这一次操作推导因果往返。诊断命名空间对同步的对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; "Upload target: %@ · designated peer: %@" = "上传目标:%@ · 指定对等设备:%@"; "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "上传、下载和往返是相互独立的证据字段。清理绝不会提升任何字段。"; "Cancel Controlled Check" = "取消受控检查"; diff --git a/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift b/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift index 090e147..89019ca 100644 --- a/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift +++ b/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift @@ -323,6 +323,7 @@ struct DiagnosticsControlledDownloadRuntimeTests { #expect(stale.phase == .timedOut) #expect(stale.evidence.uploadObserved) #expect(!stale.evidence.downloadObserved) + #expect(!stale.evidence.roundtripConfirmed) #expect(stale.completedResponsePolls == DiagnosticsUploadProtocol.pollDelays.count) let staleAuthorizations = await staleTransport.responseAuthorizations() #expect(staleAuthorizations.count == 1) @@ -373,6 +374,7 @@ struct DiagnosticsControlledDownloadRuntimeTests { await waitTerminal(tamperController) let tampered = try #require(tamperController.uploadStatuses[sharedRecord.id]) #expect(tampered.phase == .conflict) + #expect(!tampered.evidence.roundtripConfirmed) #expect(tampered.evidence.uploadObserved) #expect(!tampered.evidence.downloadObserved) @@ -388,6 +390,7 @@ struct DiagnosticsControlledDownloadRuntimeTests { await waitTerminal(generationController) let generation = try #require(generationController.uploadStatuses[sharedRecord.id]) #expect(generation.phase == .interrupted) + #expect(!generation.evidence.roundtripConfirmed) #expect(generation.evidence.uploadObserved) #expect(!generation.evidence.downloadObserved) @@ -418,6 +421,7 @@ struct DiagnosticsControlledDownloadRuntimeTests { #expect(cancelled.phase == .cancelled) #expect(cancelled.evidence.uploadObserved) #expect(!cancelled.evidence.downloadObserved) + #expect(!cancelled.evidence.roundtripConfirmed) // Scenario 5: a controller restart destroys the active correlation; // nothing resumes and no late event can set evidence. diff --git a/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift index 1755fa5..0cb3b02 100644 --- a/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift +++ b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift @@ -129,6 +129,7 @@ struct DiagnosticsForegroundUploadRuntimeTests { ) let responseRelativePath = responseComponents.joined(separator: "/") let eventBox = LockedDownloadEventBox() + let happyResponseBox = LockedUploadRequestBox() let happyRecord = record let happyFolder = folder let transport = ForegroundUploadTransport( @@ -144,6 +145,7 @@ struct DiagnosticsForegroundUploadRuntimeTests { helperKey: helperKey, now: clock.value() ) + happyResponseBox.set(response) let url = responseComponents.reduce(happyFolder) { $0.appendingPathComponent($1) } @@ -224,10 +226,10 @@ struct DiagnosticsForegroundUploadRuntimeTests { await waitForTerminalUpload(controller: controller, recordID: record.id) let status = try #require(controller.uploadStatuses[record.id]) - #expect(status.phase == .downloadObserved) + #expect(status.phase == .roundtripConfirmed) #expect(status.evidence.uploadObserved) #expect(status.evidence.downloadObserved) - #expect(!status.evidence.roundtripConfirmed) + #expect(status.evidence.roundtripConfirmed) #expect(status.completedPolls == 2) #expect(status.completedResponsePolls == 1) let queries = await transport.uploadQueries() @@ -237,6 +239,84 @@ struct DiagnosticsForegroundUploadRuntimeTests { let authorizations = await transport.responseAuthorizations() #expect(authorizations.count == 1) + // A response artifact copied from another operation can never satisfy + // a second operation: the exact-path copy fails the second chain's + // validation and terminates as conflict without download or roundtrip + // evidence. + let replayComponents = try DiagnosticsNamespaceProtocol.operationResponseComponents( + installationBinding: candidate.installationBinding, + operationID: Data(repeating: 0x21, count: 32) + ) + let replayRelative = replayComponents.joined(separator: "/") + let replayEventBox = LockedDownloadEventBox() + let replayRequestBox = LockedUploadRequestBox() + let replayTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { replayRequestBox.value() }, + acceptAfter: 1, + respond: { _ in + guard let stolen = happyResponseBox.value() else { + throw DiagnosticsProtocolError.unavailable + } + let url = replayComponents.reduce(happyFolder) { + $0.appendingPathComponent($1) + } + try stolen.write(to: url, options: .atomic) + replayEventBox.append(DiagnosticsResponseProtocol.DownloadEvent( + id: replayEventBox.nextID(), + type: "ItemFinished", + time: iso8601WithNanoseconds(clock.value().addingTimeInterval(0.5)), + data: [ + "folder": happyRecord.folderID, + "item": replayRelative, + "type": "file", + "action": "update", + "error": "", + ] + )) + } + ) + let replayController = makeUploadController( + store: store, + transport: replayTransport, + clock: clock, + random: LockedUploadRandom(values: [ + Data(repeating: 0x21, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x23, count: 32), + Data(repeating: 0x24, count: DiagnosticsUploadProtocol.payloadByteCount), + Data(repeating: 0x25, count: 32), + ]), + requestBox: replayRequestBox + ) + replayController.refresh() + await replayController.checkCapability(recordID: record.id) + replayController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true }, + events: { sinceID in + DiagnosticsResponseProtocol.DownloadEventSnapshot( + generation: 7, + events: replayEventBox.events(after: sinceID) + ) + } + ) + await waitForTerminalUpload(controller: replayController, recordID: record.id) + let replayStatus = try #require(replayController.uploadStatuses[record.id]) + #expect(replayStatus.phase == .conflict) + #expect(replayStatus.evidence.uploadObserved) + #expect(!replayStatus.evidence.downloadObserved) + #expect(!replayStatus.evidence.roundtripConfirmed) + let lateRequestBox = LockedUploadRequestBox() let lateTransport = ForegroundUploadTransport( record: record, From 8df19ae64ea024732974629a233ff90ac67cd3d3 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 11:58:24 +0200 Subject: [PATCH 2/2] Document causal roundtrip boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the M7 evidence boundary: the roundtrip field derives only from the same operation's upload-then-download chain and claims scoped causal propagation — never global sync health, future delivery, byte accounting, or a direct peer. Real-device evidence remains explicitly owner-waived; VaultSync 2.0 stays NO-GO until release and rollout. --- PRIVACY.md | 15 ++++--- docs/architecture.md | 27 ++++++----- docs/m7-causal-roundtrip-readiness.md | 65 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 docs/m7-causal-roundtrip-readiness.md diff --git a/PRIVACY.md b/PRIVACY.md index 3650bd2..f9c8934 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -136,10 +136,11 @@ call, discovery request, trust adoption, share, rescan, or Syncthing configuration/ignore change. The unreleased app source can set upload evidence after an exact pinned helper attestation and, only after an accepted upload, download evidence from a fresh local apply of the exact authorized helper -response in the same active operation; roundtrip evidence remains unset. A -complete download acceptance has run only against injected test event streams -plus byte-exact artifacts from isolated local Syncthing instances; no download -has been observed on a physical device. +response in the same active operation, and derives the causal roundtrip only +from that one upload-then-download chain. A complete download acceptance has +run only against injected test event streams plus byte-exact artifacts from +isolated local Syncthing instances; no download or roundtrip has been observed +on a physical device. Before the supported installer creates the namespace, the app must send a valid signed enablement and the local operator must choose an exact existing Syncthing @@ -268,8 +269,10 @@ signature, binding, digest, nonce, payload, and TTL validation of that file. A response existing before the baseline, arriving after an engine restart, or failing any validation can never set it; an invalid file at the exact path ends the operation as a conflict, and every terminal outcome after upload keeps the -upload field visible as a partial result. The roundtrip field remains false and -cannot be inferred from upload or download. +upload field visible as a partial result. The separate roundtrip field derives +only when this same operation's upload and download acceptances complete for +the exact validated chain; it is a scoped causal propagation claim, never +global sync health, future delivery, byte accounting, or a direct-peer claim. The active operation, request/query bytes, random values, digests, poll state, and evidence are not persisted in preferences, Keychain, logs, telemetry, diff --git a/docs/architecture.md b/docs/architecture.md index 625ad94..9af2374 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ succeeded” flag: | Local data progress observed | Background run, or one eligible server/folder check | A fresh, successful incoming file application (`ItemFinished`) | | Upload observed | Exact app/helper/homeserver/folder/operation correlation | Only an explicit foreground check in unreleased M5 app source can accept the exact paired-helper attestation for its active request/query. | | Download observed | Exact controlled response correlation | Only the same active operation in unreleased M6 app source can set it: after an accepted upload, the authorized helper response must pass a fresh post-authorization cursor/wall-clock/generation `ItemFinished` gate plus complete validation. A helper response or synchronized file alone cannot set it. | -| Full roundtrip confirmed | One matching upload-then-download correlation | Not implemented; it requires the later causal derivation from the same active chain's upload and download. | +| Full roundtrip confirmed | One matching upload-then-download correlation | Only the same active operation in unreleased M7 app source can derive it, from exactly its accepted upload then accepted download. It is a scoped propagation claim, never global sync health or future delivery. | None automatically implies the next. Relay reachability is not a trigger; trigger observation is not APNs delivery; push receipt is not background start; @@ -57,8 +57,9 @@ local data progress. A successful incoming file application proves that this iPhone applied a file change, but not that network bytes moved, which peer supplied every block, or that the check caused the change. Upload and controlled download are separate, explicitly initiated Decision 024 fields in unreleased -source; download can derive only after an accepted upload inside the same active -operation. Roundtrip is not implemented, so it cannot be derived. +source; download can derive only after an accepted upload inside the same +active operation, and the causal roundtrip derives only inside that same +operation from exactly those two acceptances. Server snapshots contain only entitlement, provisioning, backend, and per-homeserver Relay observation. The v1 push contains no homeserver/folder @@ -88,8 +89,8 @@ background sync. Ignore rules, missing paths, an event-buffer overflow, or a runtime folder error can prevent an observation and therefore end conservatively as incomplete; they never create a false success. It remains separate from the explicit controlled -operation below and cannot populate that operation's evidence. Causal roundtrip -requires its later Decision 024 milestone. Relay v1 is unchanged. +operation below and cannot populate that operation's evidence. Relay v1 is +unchanged. #### Opt-in correlated-roundtrip helper runtime — upload and controlled download @@ -99,10 +100,11 @@ immutable digest plus upgrade, downgrade, and forward-recovery path are verified. The source tree now also contains the unreleased app-side explicit capability, pairing, credential-lifecycle, and namespace-authorization control plane plus the explicit M5 foreground upload operation and the M6 controlled -download leg. Product upload is implemented to the exact signed-attestation -boundary and controlled download to the exact fresh-apply response boundary; -both remain unreleased, and causal roundtrip is unset. VaultSync 2.0 -remains NO-GO. +download leg and the M7 causal-roundtrip derivation. Product upload is +implemented to the exact signed-attestation boundary, controlled download to +the exact fresh-apply response boundary, and the causal roundtrip derives +solely from those two acceptances of one operation; all remain unreleased. +VaultSync 2.0 remains NO-GO. One upload operation begins only after a user tap and a second localized confirmation. The app rechecks the exact current capability, pairing and @@ -131,8 +133,11 @@ binding, digest, nonce, payload, and TTL validation sets `download observed`. A response predating the baseline or authorization, an engine restart, a changed binding, or any validation failure cannot set it; an invalid file at the exact path ends the operation as a conflict. Every terminal outcome after -upload keeps the upload field visible as a partial result. Roundtrip remains -immutable false in this milestone. +upload keeps the upload field visible as a partial result. When the same +operation's upload and download acceptances both complete, the causal +roundtrip derives in that acceptance from exactly this chain; it claims +scoped propagation for one operation, never global health, future delivery, +byte counts, or a direct peer. The runtime is gated by an operator-authored read-only configuration plus a separate writable state directory. If either is absent, existing helpers retain diff --git a/docs/m7-causal-roundtrip-readiness.md b/docs/m7-causal-roundtrip-readiness.md new file mode 100644 index 0000000..2593ad2 --- /dev/null +++ b/docs/m7-causal-roundtrip-readiness.md @@ -0,0 +1,65 @@ +# M7 causal-roundtrip readiness + +**Status:** Unreleased app source. The causal roundtrip derivation of +[Decision 024](decisions/024-canonical-correlated-roundtrip-contract-and-threat-model.md) +step 10 is implemented on top of the M5 upload and M6 controlled-download +legs. No new message type, endpoint, helper, bridge, Relay, or wire change +ships with this milestone. VaultSync 2.0 remains NO-GO until the release and +rollout gates complete. + +## Derivation rule + +`roundtrip confirmed` is set in exactly one place: the download acceptance of +the same active operation. That acceptance has already validated the request, +attestation, authorization, response signature, app/helper keys, epochs, +homeserver/folder bindings, operation ID, nonces, digests, payloads, and TTL +for one explicit tuple, so the roundtrip derives from exactly this +upload-then-download chain and from nothing else. + +- No timestamp, HTTP status, Relay observation, APNs, scan/index/idle state, + capability reachability, cleanup result, or tombstone can set it. +- A stale, replayed, copied, tampered, or foreign-operation response ends the + operation without download or roundtrip evidence; a response artifact copied + from another operation fails chain validation and terminates as conflict. +- Cancellation, restart, generation change, timeout, and rate limits keep the + partial upload field visible and never derive a roundtrip. +- The claim is scoped causal propagation for one operation. It is never + global sync health, future-delivery evidence, byte accounting, or a + direct-peer claim. + +## Compatibility and rollback + +The helper wire surface stays byte-identical to helper 2.0.2; capability +negotiation, pairing, namespace, upload, and response behavior are unchanged. +Old or downgraded helpers yield capability unavailable without fallback. App +or helper rollback preserves credentials, namespace authorization, opaque +artifact copies, backups, versions, conflicts, history, tombstones, mappings, +and user data. Retained copies never regain validity and cannot derive a +late roundtrip. + +## Verification + +All Xcode results and derived data are outside the repository under `/tmp`. +The local gate for this milestone includes: + +- the M5/M6 runtime suites re-run with the derivation: the exact fresh chain + ends `roundtrip confirmed` with all three evidence fields set, and every + stale, tampered, generation-changed, cancelled, restarted, rate-limited, + and cross-operation-replay scenario keeps the roundtrip field false; +- the cross-operation replay property: a valid response artifact stolen from + a completed operation and republished at a second operation's exact path + is rejected by chain validation and ends as conflict; +- cross-language golden vectors with per-byte tamper rejection (unchanged); +- both isolated two-instance Syncthing E2E tests (upload and response + transport with the real helper foundation), re-run at this head; +- the complete iOS plan, a Release-configuration simulator build, + design-token lint, string-key parity, and the sync-proof privacy lint. + +The signed owner-device test was not executed — owner-approved +physical-device waiver (2026-07-15). Simulator and isolated local Syncthing +evidence substitute for it; no hardware keychain behavior, real APNs +delivery, real background waking, or TestFlight installation on hardware is +claimed, and simulator evidence is never described as real-device evidence. + +Decision 024 remains the unchanged canonical contract. Cleanup remains +evidence-orthogonal and a later milestone.