From 7c22bf98db24956b4caaf77b8007acf6e9174cc3 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 10:56:18 +0200 Subject: [PATCH 1/3] Implement controlled response download Add the M6 controlled-download leg to the explicit D024 operation: after the exact accepted upload the app captures a fresh event-cursor, wall-clock, and engine-generation baseline, sends one signed type-6 response authorization over the pinned endpoint, and sets download observed only after a fresh ItemFinished apply of the exact expected response path plus complete type-7 chain validation. Bind product code to the cross-language M6 golden vectors and cover stale, tampered, generation-changed, cancelled, and restarted downloads plus partial-result preservation. Prove the response transport with a second two-instance Syncthing E2E driving the real helper foundation. --- .github/workflows/ci.yml | 6 +- ...agnosticsCapabilityNamespaceProtocol.swift | 12 + .../DiagnosticsPairingController.swift | 210 ++++++++- .../Services/DiagnosticsPinnedTransport.swift | 1 + .../DiagnosticsResponseProtocol.swift | 306 ++++++++++++ .../Views/ControlledDiagnosticsView.swift | 40 +- ios/VaultSync/de.lproj/Localizable.strings | 16 +- ios/VaultSync/en.lproj/Localizable.strings | 16 +- ios/VaultSync/es.lproj/Localizable.strings | 16 +- .../zh-Hans.lproj/Localizable.strings | 16 +- ...osticsControlledDownloadRuntimeTests.swift | 437 ++++++++++++++++++ ...gnosticsForegroundUploadRuntimeTests.swift | 196 +++++++- .../DiagnosticsUploadM5Tests.swift | 7 +- notify/diagnostics_contract_model_test.go | 5 +- ...diagnostics_download_syncthing_e2e_test.go | 120 +++++ 15 files changed, 1331 insertions(+), 73 deletions(-) create mode 100644 ios/VaultSync/Services/DiagnosticsResponseProtocol.swift create mode 100644 ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift create mode 100644 notify/diagnostics_download_syncthing_e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d6e0d0..1a3c8ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: run: sudo notify/tests/runtime-packaging/run-linux-host.sh m5-syncthing-upload-e2e: - name: M5 Syncthing Upload E2E + name: M5/M6 Syncthing Transfer E2E runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -148,7 +148,7 @@ jobs: GOMODCACHE: /tmp/vaultsync-m5-notify-mod run: go mod download - - name: Prove upload through two isolated ephemeral instances + - name: Prove upload and response transfer through two isolated ephemeral instances run: | docker run --rm --network none --read-only --cap-drop ALL \ --security-opt no-new-privileges \ @@ -162,7 +162,7 @@ jobs: -e VAULTSYNC_M5_SYNCTHING_BIN=/opt/vaultsync-m5-syncthing \ golang@sha256:079e59808d2d252516e27e3f3a9c003740dee7f75e55aa71528766d52bcfc16a \ go test -tags diagnostics_m5_syncthing_e2e \ - -run '^TestDiagnosticsUploadThroughTwoEphemeralSyncthingInstances$' \ + -run '^TestDiagnostics(Upload|Download)ThroughTwoEphemeralSyncthingInstances$' \ -count=1 -v install-script: diff --git a/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift index 0bfa239..15f03df 100644 --- a/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift +++ b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift @@ -673,6 +673,18 @@ enum DiagnosticsNamespaceProtocol { ] } + static func operationResponseComponents( + installationBinding: Data, + operationID: Data + ) throws -> [String] { + var components = try operationRequestComponents( + installationBinding: installationBinding, + operationID: operationID + ) + components[components.count - 1] = base32LowerNoPadding(operationID) + ".response.cbor" + return components + } + private static func unixSeconds(_ date: Date) throws -> UInt64 { let value = date.timeIntervalSince1970.rounded(.down) guard value >= 0, value < Double(UInt64.max) else { diff --git a/ios/VaultSync/Services/DiagnosticsPairingController.swift b/ios/VaultSync/Services/DiagnosticsPairingController.swift index cbc2e4a..efc27f8 100644 --- a/ios/VaultSync/Services/DiagnosticsPairingController.swift +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -28,6 +28,7 @@ final class DiagnosticsPairingController { case preflighting case checking case uploadObserved + case downloadObserved case cancelled case timedOut case interrupted @@ -39,7 +40,7 @@ final class DiagnosticsPairingController { struct UploadEvidence: Equatable, Sendable { var uploadObserved = false - let downloadObserved = false + var downloadObserved = false let roundtripConfirmed = false } @@ -47,6 +48,7 @@ final class DiagnosticsPairingController { var phase: UploadPhase var evidence = UploadEvidence() var completedPolls = 0 + var completedResponsePolls = 0 } private struct UploadTuple: Hashable, Sendable { @@ -71,6 +73,9 @@ final class DiagnosticsPairingController { _ requireEmptySlot: Bool ) -> DiagnosticsUploadPreflight typealias UploadRescan = @MainActor () -> Bool + typealias UploadEventsProvider = @MainActor ( + _ sinceID: Int64 + ) -> DiagnosticsResponseProtocol.DownloadEventSnapshot? private let credentialStore: DiagnosticsCredentialStore private let transportFactory: TransportFactory @@ -387,7 +392,8 @@ final class DiagnosticsPairingController { func beginForegroundUpload( recordID: String, preflight: @escaping UploadPreflightProvider, - rescan: @escaping UploadRescan + rescan: @escaping UploadRescan, + events: @escaping UploadEventsProvider ) { guard uploadTasks[recordID] == nil else { return } lastError = nil @@ -400,7 +406,8 @@ final class DiagnosticsPairingController { recordID: recordID, runID: runID, preflight: preflight, - rescan: rescan + rescan: rescan, + events: events ) } uploadTasks[recordID] = task @@ -410,11 +417,12 @@ final class DiagnosticsPairingController { guard let task = uploadTasks[recordID] else { return } task.cancel() if let status = uploadStatuses[recordID], - [.preflighting, .checking].contains(status.phase) { + [.preflighting, .checking, .uploadObserved].contains(status.phase) { uploadStatuses[recordID] = UploadStatus( phase: .cancelled, evidence: status.evidence, - completedPolls: status.completedPolls + completedPolls: status.completedPolls, + completedResponsePolls: status.completedResponsePolls ) } } @@ -429,7 +437,8 @@ final class DiagnosticsPairingController { recordID: String, runID: UUID, preflight: @escaping UploadPreflightProvider, - rescan: @escaping UploadRescan + rescan: @escaping UploadRescan, + events: @escaping UploadEventsProvider ) async { var tupleKey: UploadTuple? var artifactCreated = false @@ -585,7 +594,7 @@ final class DiagnosticsPairingController { guard finalRequest == operation.request.canonical else { throw DiagnosticsProtocolError.conflict } - _ = try DiagnosticsUploadProtocol.validateUploadAttestation( + let attestation = try DiagnosticsUploadProtocol.validateUploadAttestation( response, operation: operation, record: finalRecord, @@ -596,17 +605,33 @@ final class DiagnosticsPairingController { evidence: UploadEvidence(uploadObserved: true), completedPolls: index + 1 ) + try await runControlledDownload( + recordID: recordID, + runID: runID, + record: record, + operation: operation, + attestation: attestation, + exactPreflight: exactPreflight, + installationComponent: installationComponent, + operationComponent: operationComponent, + preflight: preflight, + events: events, + start: start, + deadline: deadline, + completedUploadPolls: index + 1 + ) return } throw DiagnosticsProtocolError.expired } catch is CancellationError { if uploadRunIDs[recordID] == runID, let status = uploadStatuses[recordID], - [.preflighting, .checking].contains(status.phase) { + [.preflighting, .checking, .uploadObserved].contains(status.phase) { uploadStatuses[recordID] = UploadStatus( phase: .cancelled, evidence: status.evidence, - completedPolls: status.completedPolls + completedPolls: status.completedPolls, + completedResponsePolls: status.completedResponsePolls ) } } catch let error as DiagnosticsProtocolError { @@ -623,6 +648,170 @@ final class DiagnosticsPairingController { } } + // D024 steps 7-9: the response gate runs only after upload acceptance in + // the same active operation, against a fresh post-upload cursor, wall + // clock, and unchanged engine generation. A response applied before this + // baseline or after an engine restart can never set download evidence. + private func runControlledDownload( + recordID: String, + runID: UUID, + record: DiagnosticsPairingRecord, + operation: DiagnosticsUploadProtocol.Operation, + attestation: DiagnosticsUploadProtocol.Message, + exactPreflight: DiagnosticsUploadPreflight, + installationComponent: String, + operationComponent: String, + preflight: @escaping UploadPreflightProvider, + events: @escaping UploadEventsProvider, + start: TimeInterval, + deadline: TimeInterval, + completedUploadPolls: Int + ) async throws { + guard let baseline = events(0), + baseline.generation != 0, + baseline.generation == exactPreflight.engineGeneration else { + throw DiagnosticsProtocolError.unavailable + } + var cursor = baseline.events.map(\.id).max() ?? 0 + let baselineDate = now() + guard let confirm = events(cursor), + confirm.generation == baseline.generation else { + throw DiagnosticsProtocolError.unavailable + } + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let authorization = try DiagnosticsResponseProtocol.makeAuthorization( + record: record, + appKey: appKey, + operation: operation, + attestation: attestation, + authorizationNonce: try uploadRandomBytes(32), + now: now() + ) + let responseComponents = try DiagnosticsNamespaceProtocol.operationResponseComponents( + installationBinding: operation.installationBinding, + operationID: operation.operationID + ) + let responseRelativePath = responseComponents.joined(separator: "/") + var authorizationAccepted = false + + for (index, delay) in DiagnosticsUploadProtocol.pollDelays.enumerated() { + try requireCurrentUploadRun(recordID: recordID, runID: runID) + try await uploadSleep(delay) + try requireCurrentUploadRun(recordID: recordID, runID: runID) + let currentContinuous = continuousNow() + guard currentContinuous.isFinite, currentContinuous >= start else { + throw DiagnosticsProtocolError.unavailable + } + guard currentContinuous < deadline else { + throw DiagnosticsProtocolError.expired + } + let currentRecord = try requiredRecord(recordID) + guard uploadBindingUnchanged(record, currentRecord) else { + throw DiagnosticsProtocolError.unavailable + } + let currentPreflight = preflight(installationComponent, operationComponent, false) + try currentPreflight.validate(record: currentRecord, requireEmptySlot: false) + guard currentPreflight.sameRuntimeBoundary(as: exactPreflight) else { + throw DiagnosticsProtocolError.unavailable + } + let installation = try DiagnosticsUploadProtocol.verifyActiveNamespace( + record: currentRecord, + folderPath: currentPreflight.folderPath + ) + guard installation == operation.installationBinding else { + throw DiagnosticsProtocolError.conflict + } + let persistedRequest = try DiagnosticsNamespaceFileReader.read( + folderPath: currentPreflight.folderPath, + components: operation.requestComponents + ) + guard persistedRequest == operation.request.canonical else { + throw DiagnosticsProtocolError.conflict + } + + if !authorizationAccepted { + // Retransmit the byte-identical signed authorization until the + // idempotent helper accepts it; a 202 carries no body and is + // transport diagnostics only, never download evidence. + try consumeUploadRequest(recordID: recordID) + let transport = try makeTransport(currentRecord) + let accepted = try await transport.post( + path: DiagnosticsResponseProtocol.path, + body: authorization.canonical, + responseBody: false + ) + try requireCurrentUploadRun(recordID: recordID, runID: runID) + guard accepted == nil else { + throw DiagnosticsProtocolError.invalidMessage + } + authorizationAccepted = true + } + + guard let snapshot = events(cursor), + snapshot.generation == baseline.generation else { + throw DiagnosticsProtocolError.unavailable + } + var status = uploadStatuses[recordID] ?? UploadStatus(phase: .uploadObserved) + status.completedResponsePolls = index + 1 + uploadStatuses[recordID] = status + let fresh = snapshot.events.first { + DiagnosticsResponseProtocol.freshResponseApply( + $0, + folderID: record.folderID, + relativePath: responseRelativePath, + baseline: baselineDate, + cursor: cursor + ) + } + cursor = max(cursor, snapshot.events.map(\.id).max() ?? cursor) + guard fresh != nil else { continue } + + let finalRecord = try requiredRecord(recordID) + guard uploadBindingUnchanged(record, finalRecord) else { + throw DiagnosticsProtocolError.unavailable + } + let finalPreflight = preflight(installationComponent, operationComponent, false) + try finalPreflight.validate(record: finalRecord, requireEmptySlot: false) + guard finalPreflight.sameRuntimeBoundary(as: exactPreflight) else { + throw DiagnosticsProtocolError.unavailable + } + let finalInstallation = try DiagnosticsUploadProtocol.verifyActiveNamespace( + record: finalRecord, + folderPath: finalPreflight.folderPath + ) + guard finalInstallation == operation.installationBinding else { + throw DiagnosticsProtocolError.conflict + } + let responseData = try DiagnosticsNamespaceFileReader.read( + folderPath: finalPreflight.folderPath, + components: responseComponents + ) + do { + _ = try DiagnosticsResponseProtocol.validateResponseArtifact( + responseData, + operation: operation, + attestation: attestation, + authorization: authorization, + record: finalRecord, + now: now() + ) + } catch DiagnosticsProtocolError.invalidMessage { + // Invalid bytes at the exact expected namespace path are + // unexpected authenticated namespace content (D024: conflict), + // not a pinned-channel protocol mismatch. + throw DiagnosticsProtocolError.conflict + } + uploadStatuses[recordID] = UploadStatus( + phase: .downloadObserved, + evidence: UploadEvidence(uploadObserved: true, downloadObserved: true), + completedPolls: completedUploadPolls, + completedResponsePolls: index + 1 + ) + return + } + throw DiagnosticsProtocolError.expired + } + private func requireCurrentUploadRun(recordID: String, runID: UUID) throws { try Task.checkCancellation() guard uploadRunIDs[recordID] == runID else { @@ -741,7 +930,8 @@ final class DiagnosticsPairingController { uploadStatuses[recordID] = UploadStatus( phase: phase, evidence: existing.evidence, - completedPolls: existing.completedPolls + completedPolls: existing.completedPolls, + completedResponsePolls: existing.completedResponsePolls ) } diff --git a/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift index 75d8e5b..8b2a0a1 100644 --- a/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift +++ b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift @@ -44,6 +44,7 @@ final class DiagnosticsPinnedTransport: DiagnosticsTransporting, @unchecked Send DiagnosticsNamespaceProtocol.enablementPath, DiagnosticsNamespaceProtocol.authorizationPath, DiagnosticsUploadProtocol.path, + DiagnosticsResponseProtocol.path, ] guard allowedPaths.contains(path), !body.isEmpty, body.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes else { diff --git a/ios/VaultSync/Services/DiagnosticsResponseProtocol.swift b/ios/VaultSync/Services/DiagnosticsResponseProtocol.swift new file mode 100644 index 0000000..879ec51 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsResponseProtocol.swift @@ -0,0 +1,306 @@ +import CryptoKit +import Foundation + +enum DiagnosticsResponseProtocol { + static let path = "/api/v1/diagnostics/authorize-response" + + enum MessageType: UInt64, Sendable { + case responseAuthorization = 6 + case responseArtifact = 7 + } + + struct Message: Equatable, Sendable { + let type: MessageType + let canonical: Data + let value: DiagnosticsCBORValue + let body: Data + let digest: Data + } + + private static let domains: [MessageType: String] = [ + .responseAuthorization: "eu.vaultsync.roundtrip/v1/response-authorization\0", + .responseArtifact: "eu.vaultsync.roundtrip/v1/response-artifact\0", + ] + + private static let expectedLabels: [MessageType: [UInt64]] = [ + .responseAuthorization: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 17, 20, 21, 255], + .responseArtifact: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 17, 20, 22, 23, 24, 25, 255], + ] + + static func makeAuthorization( + record: DiagnosticsPairingRecord, + appKey: Curve25519.Signing.PrivateKey, + operation: DiagnosticsUploadProtocol.Operation, + attestation: DiagnosticsUploadProtocol.Message, + authorizationNonce: Data, + now: Date + ) throws -> Message { + guard record.state == .namespaceActive, + appKey.publicKey.rawRepresentation == record.appPublicKey, + attestation.type == .uploadAttestation, + authorizationNonce.count == 32, + authorizationNonce.contains(where: { $0 != 0 }), + let requestIssued = operation.request.value.unsigned(for: 12), + let requestExpiry = operation.request.value.unsigned(for: 13), + let attestationExpiry = attestation.value.unsigned(for: 13) else { + throw DiagnosticsProtocolError.invalidMessage + } + let issuedAt = try unixSeconds(now) + // The authorization can never outlive the request or attestation it + // binds; the helper enforces the same chain ordering. + let expiresAt = min( + try DiagnosticsPairingProtocol.checkedAdding(issuedAt, DiagnosticsUploadProtocol.maximumLifetime), + requestExpiry, + attestationExpiry + ) + guard issuedAt >= requestIssued, expiresAt > issuedAt else { + throw DiagnosticsProtocolError.expired + } + let fields: [DiagnosticsCBORField] = [ + DiagnosticsCBORField(label: 1, value: .text(DiagnosticsUploadProtocol.capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(MessageType.responseAuthorization.rawValue)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 8, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 9, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 10, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 11, value: .bytes(operation.operationID)), + DiagnosticsCBORField(label: 12, value: .unsigned(issuedAt)), + DiagnosticsCBORField(label: 13, value: .unsigned(expiresAt)), + DiagnosticsCBORField(label: 17, value: .bytes(operation.request.digest)), + DiagnosticsCBORField(label: 20, value: .bytes(attestation.digest)), + DiagnosticsCBORField(label: 21, value: .bytes(authorizationNonce)), + ] + let authorization = try sign(.map(fields), as: .responseAuthorization, with: appKey, record: record) + try validateAuthorizationChain( + operation: operation, + attestation: attestation, + authorization: authorization + ) + return authorization + } + + static func validateResponseArtifact( + _ data: Data, + operation: DiagnosticsUploadProtocol.Operation, + attestation: DiagnosticsUploadProtocol.Message, + authorization: Message, + record: DiagnosticsPairingRecord, + now: Date + ) throws -> Message { + try validateClock(operation.request.value, now: now) + try validateClock(authorization.value, now: now) + let response = try decode(data, record: record) + try validateClock(response.value, now: now) + try validateAuthorizationChain( + operation: operation, + attestation: attestation, + authorization: authorization + ) + guard response.type == .responseArtifact, + commonFieldsEqual(authorization.value, response.value), + response.value.bytes(for: 17, count: 32) == operation.request.digest, + response.value.bytes(for: 20, count: 32) == attestation.digest, + response.value.bytes(for: 22, count: 32) == authorization.digest, + let responseExpiry = response.value.unsigned(for: 13), + let requestExpiry = operation.request.value.unsigned(for: 13), + let attestationExpiry = attestation.value.unsigned(for: 13), + let authorizationExpiry = authorization.value.unsigned(for: 13), + responseExpiry <= requestExpiry, + responseExpiry <= attestationExpiry, + responseExpiry <= authorizationExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + return response + } + + static func decode(_ data: Data, record: DiagnosticsPairingRecord) throws -> Message { + let value = try DiagnosticsDeterministicCBOR.decode(data) + guard let rawType = value.unsigned(for: 4), + let type = MessageType(rawValue: rawType), + value.fields?.map(\.label) == expectedLabels[type], + let domain = domains[type] else { + throw DiagnosticsProtocolError.invalidMessage + } + try validateFields(value, type: type, record: record) + guard let signature = value.bytes(for: 255, count: 64) else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value.removing(labels: [255])) + var signedInput = Data(domain.utf8) + signedInput.append(body) + let publicBytes = type == .responseArtifact ? record.helperPublicKey : record.appPublicKey + let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicBytes) + guard publicKey.isValidSignature(signature, for: signedInput) else { + throw DiagnosticsProtocolError.invalidMessage + } + return Message( + type: type, + canonical: data, + value: value, + body: body, + digest: DiagnosticsCrypto.sha256(domain: domain, body: body) + ) + } + + struct DownloadEvent: Decodable, Equatable, Sendable { + let id: Int64 + let type: String + let time: String + let data: [String: String]? + } + + struct DownloadEventSnapshot: Equatable, Sendable { + let generation: Int64 + let events: [DownloadEvent] + } + + static func eventSnapshot(generation: Int64, json: String) -> DownloadEventSnapshot? { + guard let data = json.data(using: .utf8), + let events = try? JSONDecoder().decode([DownloadEvent].self, from: data) else { + return nil + } + return DownloadEventSnapshot(generation: generation, events: events) + } + + // A response artifact may only be accepted from a successful local apply + // of the exact expected path that is newer than both the post-upload + // cursor and wall-clock baselines (D024 step 9); anything else is stale. + static func freshResponseApply( + _ event: DownloadEvent, + folderID: String, + relativePath: String, + baseline: Date, + cursor: Int64 + ) -> Bool { + guard event.id > cursor, + event.type == "ItemFinished", + let data = event.data, + data["folder"] == folderID, + data["item"] == relativePath, + data["type"] == "file", + data["action"] == "update", + data["error", default: ""].isEmpty, + let observedAt = SyncBridgeService.parseBridgeTimestamp(event.time), + observedAt >= baseline else { + return false + } + return true + } + + private static func sign( + _ value: DiagnosticsCBORValue, + as type: MessageType, + with key: Curve25519.Signing.PrivateKey, + record: DiagnosticsPairingRecord + ) throws -> Message { + guard let domain = domains[type], case .map(var fields) = value else { + throw DiagnosticsProtocolError.invalidMessage + } + let body = try DiagnosticsDeterministicCBOR.encode(value) + var signedInput = Data(domain.utf8) + signedInput.append(body) + fields.append(DiagnosticsCBORField(label: 255, value: .bytes(try key.signature(for: signedInput)))) + return try decode(try DiagnosticsDeterministicCBOR.encode(.map(fields)), record: record) + } + + private static func validateAuthorizationChain( + operation: DiagnosticsUploadProtocol.Operation, + attestation: DiagnosticsUploadProtocol.Message, + authorization: Message + ) throws { + guard operation.request.type == .operationRequest, + attestation.type == .uploadAttestation, + authorization.type == .responseAuthorization, + commonFieldsEqual(operation.request.value, authorization.value), + authorization.value.bytes(for: 17, count: 32) == operation.request.digest, + authorization.value.bytes(for: 20, count: 32) == attestation.digest, + let requestIssued = operation.request.value.unsigned(for: 12), + let requestExpiry = operation.request.value.unsigned(for: 13), + let attestationExpiry = attestation.value.unsigned(for: 13), + let authorizationIssued = authorization.value.unsigned(for: 12), + let authorizationExpiry = authorization.value.unsigned(for: 13), + authorizationIssued >= requestIssued, + authorizationExpiry <= requestExpiry, + authorizationExpiry <= attestationExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func validateFields( + _ value: DiagnosticsCBORValue, + type: MessageType, + record: DiagnosticsPairingRecord + ) throws { + guard value.text(for: 1) == DiagnosticsUploadProtocol.capability, + value.unsigned(for: 2) == 1, + value.unsigned(for: 3) == 1, + value.unsigned(for: 4) == type.rawValue, + value.bytes(for: 5, count: 32) == record.homeserverBinding, + value.bytes(for: 6, count: 32) == record.folderBinding, + value.bytes(for: 7, count: 32) == record.appKeyID, + value.bytes(for: 8, count: 32) == record.helperKeyID, + value.unsigned(for: 9) == record.appEpoch, + value.unsigned(for: 10) == record.helperEpoch, + value.bytes(for: 11, count: 32)?.contains(where: { $0 != 0 }) == true, + record.appKeyID == DiagnosticsCrypto.keyID(publicKey: record.appPublicKey), + record.helperKeyID == DiagnosticsCrypto.keyID(publicKey: record.helperPublicKey), + let issuedAt = value.unsigned(for: 12), issuedAt > 0, + let expiresAt = value.unsigned(for: 13), + expiresAt > issuedAt, + expiresAt - issuedAt <= DiagnosticsUploadProtocol.maximumLifetime else { + throw DiagnosticsProtocolError.invalidMessage + } + switch type { + case .responseAuthorization: + guard value.bytes(for: 17, count: 32) != nil, + value.bytes(for: 20, count: 32) != nil, + value.bytes(for: 21, count: 32)?.contains(where: { $0 != 0 }) == true else { + throw DiagnosticsProtocolError.invalidMessage + } + case .responseArtifact: + guard value.bytes(for: 17, count: 32) != nil, + value.bytes(for: 20, count: 32) != nil, + value.bytes(for: 22, count: 32) != nil, + value.bytes(for: 23, count: 32)?.contains(where: { $0 != 0 }) == true, + let payload = value.bytes(for: 24, count: DiagnosticsUploadProtocol.payloadByteCount), + value.bytes(for: 25, count: 32) == DiagnosticsCrypto.sha256(payload) else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } + + private static func validateClock(_ value: DiagnosticsCBORValue, now: Date) throws { + guard let issuedAt = value.unsigned(for: 12), + let expiresAt = value.unsigned(for: 13) else { + throw DiagnosticsProtocolError.invalidMessage + } + let current = try unixSeconds(now) + if issuedAt > current, issuedAt - current > DiagnosticsUploadProtocol.maximumClockSkew { + throw DiagnosticsProtocolError.expired + } + if current > expiresAt, current - expiresAt > DiagnosticsUploadProtocol.maximumClockSkew { + throw DiagnosticsProtocolError.expired + } + } + + private static func commonFieldsEqual( + _ lhs: DiagnosticsCBORValue, + _ rhs: DiagnosticsCBORValue + ) -> Bool { + [1, 2, 3, 5, 6, 7, 8, 9, 10, 11].allSatisfy { + lhs.value(for: $0) == rhs.value(for: $0) + } + } + + private static func unixSeconds(_ date: Date) throws -> UInt64 { + let seconds = date.timeIntervalSince1970.rounded(.down) + guard seconds >= 0, seconds < Double(UInt64.max) else { + throw DiagnosticsProtocolError.invalidMessage + } + return UInt64(seconds) + } +} diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift index b6a841c..a7507a7 100644 --- a/ios/VaultSync/Views/ControlledDiagnosticsView.swift +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -83,15 +83,15 @@ struct ControlledDiagnosticsView: View { } message: { Text(L10n.tr("This removes only this app's local diagnostics credentials. It does not revoke the old helper authorization. Re-pair with a new QR, then ask the helper operator to revoke the lost app fingerprint.")) } - .alert(L10n.tr("Start controlled upload check?"), isPresented: $showUploadConsent) { + .alert(L10n.tr("Start controlled upload and download check?"), isPresented: $showUploadConsent) { Button(L10n.tr("Cancel"), role: .cancel) { pendingUploadRecordID = nil } - Button(L10n.tr("Start Upload Check")) { + Button(L10n.tr("Start Upload and Download Check")) { 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. Download and roundtrip remain 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. Roundtrip remains unobserved. 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 upload check may mark upload observed; download and roundtrip remain 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 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.")) .font(.caption) .foregroundStyle(.secondary) } header: { @@ -336,12 +336,12 @@ struct ControlledDiagnosticsView: View { .foregroundStyle(.secondary) } if let status = controller.uploadStatuses[record.id], - [.preflighting, .checking].contains(status.phase) { - Button(L10n.tr("Cancel Upload Check"), role: .cancel) { + [.preflighting, .checking, .uploadObserved].contains(status.phase) { + Button(L10n.tr("Cancel Controlled Check"), role: .cancel) { controller.cancelForegroundUpload(recordID: record.id) } } else if capability == .available { - Button(L10n.tr("Start Foreground Upload Check")) { + Button(L10n.tr("Start Foreground Upload and Download Check")) { pendingUploadRecordID = record.id showUploadConsent = true } @@ -535,18 +535,37 @@ struct ControlledDiagnosticsView: View { }, rescan: { syncthingManager.rescanFolder(id: record.folderID) == nil + }, + events: { sinceID in + DiagnosticsResponseProtocol.eventSnapshot( + generation: SyncBridgeService.eventStreamGeneration(), + json: SyncBridgeService.getEventsSince(lastID: Int(sinceID)) + ) } ) } private func uploadStatusLabel(_ status: DiagnosticsPairingController.UploadStatus) -> String { + if status.evidence.uploadObserved { + switch status.phase { + case .uploadObserved: + return L10n.fmt( + "Upload observed — download response pending after %d of 8 polls", + status.completedResponsePolls + ) + case .downloadObserved: + return L10n.tr("Upload and download observed — roundtrip remains unobserved") + default: + return L10n.tr("Partial: upload observed, download unobserved — no late result can upgrade it") + } + } switch status.phase { case .preflighting: return L10n.tr("Checking exact upload preconditions — no artifact created") case .checking: return L10n.fmt("Upload pending after %d of 8 exact polls", status.completedPolls) - case .uploadObserved: - return L10n.tr("Upload observed — download and roundtrip unobserved") + case .uploadObserved, .downloadObserved: + return L10n.tr("Upload and download observed — roundtrip remains unobserved") case .cancelled: return L10n.tr("Upload check cancelled — no late result can upgrade it") case .timedOut: @@ -567,6 +586,7 @@ struct ControlledDiagnosticsView: View { private func uploadStatusSymbol(_ phase: DiagnosticsPairingController.UploadPhase) -> String { switch phase { case .uploadObserved: return "arrow.up.circle.fill" + case .downloadObserved: return "arrow.down.circle.fill" case .preflighting, .checking: return "hourglass" case .cancelled, .timedOut, .interrupted, .unavailable: return "exclamationmark.circle" case .conflict, .rateLimited, .unsupported: return "xmark.shield" @@ -575,7 +595,7 @@ struct ControlledDiagnosticsView: View { private func uploadStatusColor(_ phase: DiagnosticsPairingController.UploadPhase) -> Color { switch phase { - case .uploadObserved: return Color.statusSuccess + case .uploadObserved, .downloadObserved: 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 1307d1d..42d4f72 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -914,18 +914,20 @@ "Cancel Pending Pairing" = "Ausstehendes Pairing abbrechen"; "Discard Expired Pairing" = "Abgelaufenes Pairing verwerfen"; "Retry Exact Pairing Cancellation" = "Exakten Pairing-Abbruch wiederholen"; -"Start controlled upload check?" = "Kontrollierte Upload-Prüfung starten?"; -"Start Upload Check" = "Upload-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. Download and roundtrip remain 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. Download und Roundtrip bleiben 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 upload check may mark upload observed; download and roundtrip remain 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 Upload-Prüfung kann den Upload als beobachtet markieren; Download und Roundtrip bleiben unabhängig. Der Diagnose-Namespace ist für synchronisierte Peers sichtbar und kann in Backups, Versionen, Konfliktkopien und Tombstones verbleiben."; +"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."; "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 Upload Check" = "Upload-Prüfung abbrechen"; -"Start Foreground Upload Check" = "Upload-Prüfung im Vordergrund starten"; +"Cancel Controlled Check" = "Kontrollierte Prüfung abbrechen"; +"Start Foreground Upload and Download Check" = "Upload- und Download-Prüfung im Vordergrund starten"; "Check authenticated capability immediately before starting an upload check." = "Prüfe die authentisierte Capability unmittelbar vor dem Start einer Upload-Prüfung."; "Checking exact upload preconditions — no artifact created" = "Exakte Upload-Vorbedingungen werden geprüft — kein Artefakt erstellt"; "Upload pending after %d of 8 exact polls" = "Upload nach %d von 8 exakten Abfragen noch ausstehend"; -"Upload observed — download and roundtrip unobserved" = "Upload beobachtet — Download und Roundtrip unbeobachtet"; +"Upload observed — download response pending after %d of 8 polls" = "Upload beobachtet — Download-Antwort nach %d von 8 Abfragen noch ausstehend"; +"Upload and download observed — roundtrip remains unobserved" = "Upload und Download beobachtet — Roundtrip bleibt unbeobachtet"; +"Partial: upload observed, download unobserved — no late result can upgrade it" = "Teilergebnis: Upload beobachtet, Download unbeobachtet — kein spätes Ergebnis kann es aufwerten"; "Upload check cancelled — no late result can upgrade it" = "Upload-Prüfung abgebrochen — kein spätes Ergebnis kann sie aufwerten"; "Upload check timed out — upload unobserved" = "Zeitlimit der Upload-Prüfung erreicht — Upload unbeobachtet"; "Upload check interrupted — upload unobserved" = "Upload-Prüfung unterbrochen — Upload unbeobachtet"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 65fffc2..76cdd46 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -914,18 +914,20 @@ "Cancel Pending Pairing" = "Cancel Pending Pairing"; "Discard Expired Pairing" = "Discard Expired Pairing"; "Retry Exact Pairing Cancellation" = "Retry Exact Pairing Cancellation"; -"Start controlled upload check?" = "Start controlled upload check?"; -"Start Upload Check" = "Start Upload 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. Download and roundtrip remain 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. Download and roundtrip remain 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 upload check may mark upload observed; download and roundtrip remain 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 upload check may mark upload observed; download and roundtrip remain independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones."; +"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."; "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 Upload Check" = "Cancel Upload Check"; -"Start Foreground Upload Check" = "Start Foreground Upload Check"; +"Cancel Controlled Check" = "Cancel Controlled Check"; +"Start Foreground Upload and Download Check" = "Start Foreground Upload and Download Check"; "Check authenticated capability immediately before starting an upload check." = "Check authenticated capability immediately before starting an upload check."; "Checking exact upload preconditions — no artifact created" = "Checking exact upload preconditions — no artifact created"; "Upload pending after %d of 8 exact polls" = "Upload pending after %d of 8 exact polls"; -"Upload observed — download and roundtrip unobserved" = "Upload observed — download and roundtrip unobserved"; +"Upload observed — download response pending after %d of 8 polls" = "Upload observed — download response pending after %d of 8 polls"; +"Upload and download observed — roundtrip remains unobserved" = "Upload and download observed — roundtrip remains unobserved"; +"Partial: upload observed, download unobserved — no late result can upgrade it" = "Partial: upload observed, download unobserved — no late result can upgrade it"; "Upload check cancelled — no late result can upgrade it" = "Upload check cancelled — no late result can upgrade it"; "Upload check timed out — upload unobserved" = "Upload check timed out — upload unobserved"; "Upload check interrupted — upload unobserved" = "Upload check interrupted — upload unobserved"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 93892a2..9fbd784 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -914,18 +914,20 @@ "Cancel Pending Pairing" = "Cancelar emparejamiento pendiente"; "Discard Expired Pairing" = "Descartar emparejamiento caducado"; "Retry Exact Pairing Cancellation" = "Reintentar cancelación exacta del emparejamiento"; -"Start controlled upload check?" = "¿Iniciar la comprobación controlada de carga?"; -"Start Upload Check" = "Iniciar comprobación de carga"; -"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. Download and roundtrip remain 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. La descarga y el viaje de ida y vuelta permanecen 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 upload check may mark upload observed; download and roundtrip remain 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 de carga explícita y separada puede marcar la carga como observada; la descarga y el viaje de ida y vuelta siguen siendo independientes. 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."; +"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."; "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 Upload Check" = "Cancelar comprobación de carga"; -"Start Foreground Upload Check" = "Iniciar comprobación de carga en primer plano"; +"Cancel Controlled Check" = "Cancelar comprobación controlada"; +"Start Foreground Upload and Download Check" = "Iniciar comprobación de carga y descarga en primer plano"; "Check authenticated capability immediately before starting an upload check." = "Comprueba la capacidad autenticada justo antes de iniciar una comprobación de carga."; "Checking exact upload preconditions — no artifact created" = "Comprobando las precondiciones exactas de carga — no se creó ningún artefacto"; "Upload pending after %d of 8 exact polls" = "Carga pendiente tras %d de 8 consultas exactas"; -"Upload observed — download and roundtrip unobserved" = "Carga observada — descarga y viaje de ida y vuelta sin observar"; +"Upload observed — download response pending after %d of 8 polls" = "Carga observada — respuesta de descarga pendiente tras %d de 8 consultas"; +"Upload and download observed — roundtrip remains unobserved" = "Carga y descarga observadas — el viaje de ida y vuelta permanece sin observar"; +"Partial: upload observed, download unobserved — no late result can upgrade it" = "Parcial: carga observada, descarga sin observar — ningún resultado tardío puede mejorarlo"; "Upload check cancelled — no late result can upgrade it" = "Comprobación de carga cancelada — ningún resultado tardío puede mejorarla"; "Upload check timed out — upload unobserved" = "La comprobación de carga agotó el tiempo — carga sin observar"; "Upload check interrupted — upload unobserved" = "Comprobación de carga interrumpida — carga sin observar"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 9a7bc0a..00043d1 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -914,18 +914,20 @@ "Cancel Pending Pairing" = "取消待处理配对"; "Discard Expired Pairing" = "丢弃已过期配对"; "Retry Exact Pairing Cancellation" = "重试原配对取消请求"; -"Start controlled upload check?" = "开始受控上传检查?"; -"Start Upload 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. Download and roundtrip remain unobserved. Opaque copies may remain in peers, backups, versions, conflicts, or tombstones." = "VaultSync 将在已授权的诊断命名空间中创建一个包含 256 个随机字节的签名请求,并且只重新扫描所选文件夹。只有来自已固定 helper、精确绑定且签名有效的回复才能将上传标记为已观察。下载和往返仍为未观察。不透明副本可能保留在对等设备、备份、版本、冲突副本或删除记录中。"; -"Pairing and capability checks create no upload, download, or roundtrip evidence. Only a separate explicit upload check may mark upload observed; download and roundtrip remain independent. The diagnostics namespace is visible to synchronized peers and may remain in backups, versions, conflict copies, and tombstones." = "配对和能力检查不会产生上传、下载或往返证据。只有单独且明确启动的上传检查才能将上传标记为已观察;下载和往返始终独立。诊断命名空间对同步的对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; +"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." = "配对和能力检查不会产生上传、下载或往返证据。只有单独且明确启动的检查才能将上传标记为已观察,并在其后将下载标记为已观察;往返始终独立。诊断命名空间对同步的对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; "Upload target: %@ · designated peer: %@" = "上传目标:%@ · 指定对等设备:%@"; "Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "上传、下载和往返是相互独立的证据字段。清理绝不会提升任何字段。"; -"Cancel Upload Check" = "取消上传检查"; -"Start Foreground Upload Check" = "开始前台上传检查"; +"Cancel Controlled Check" = "取消受控检查"; +"Start Foreground Upload and Download Check" = "开始前台上传和下载检查"; "Check authenticated capability immediately before starting an upload check." = "请在开始上传检查前立即检查已认证能力。"; "Checking exact upload preconditions — no artifact created" = "正在检查精确的上传前置条件 — 尚未创建工件"; "Upload pending after %d of 8 exact polls" = "完成 8 次精确轮询中的 %d 次后,上传仍待确认"; -"Upload observed — download and roundtrip unobserved" = "已观察到上传 — 下载和往返未观察"; +"Upload observed — download response pending after %d of 8 polls" = "已观察到上传 — 完成 8 次轮询中的 %d 次后,下载响应仍待确认"; +"Upload and download observed — roundtrip remains unobserved" = "已观察到上传和下载 — 往返仍未观察"; +"Partial: upload observed, download unobserved — no late result can upgrade it" = "部分结果:已观察到上传,下载未观察 — 任何迟到的结果都无法提升状态"; "Upload check cancelled — no late result can upgrade it" = "上传检查已取消 — 任何迟到结果都不能提升状态"; "Upload check timed out — upload unobserved" = "上传检查超时 — 未观察到上传"; "Upload check interrupted — upload unobserved" = "上传检查中断 — 未观察到上传"; diff --git a/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift b/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift new file mode 100644 index 0000000..090e147 --- /dev/null +++ b/ios/VaultSyncTests/DiagnosticsControlledDownloadRuntimeTests.swift @@ -0,0 +1,437 @@ +import CryptoKit +import Foundation +import Testing +@testable import VaultSync + +@Suite("Controlled diagnostics download runtime (M6)", .serialized) +@MainActor +struct DiagnosticsControlledDownloadRuntimeTests { + @Test("Production response protocol matches the cross-language M6 golden vectors") + func productionGoldenVectors() throws { + let m5 = try M5UploadFixtureLoader.load() + let m6 = try M6ResponseFixtureLoader.load() + let golden = try M6ResponseGoldenMessages.make(m5: m5, m6: m6) + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(m1Hex: m5.appSeedHex)) + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(m1Hex: m5.helperSeedHex)) + var record = makeRuntimeRecord( + appSeed: appKey.rawRepresentation, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: try Data(m1Hex: m5.homeserverBindingHex), + folder: try Data(m1Hex: m5.folderBindingHex), + appEpoch: m5.appEpoch, + helperEpoch: m5.helperEpoch + ) + record.state = .namespaceActive + record.namespaceAuthorizationEpoch = 1 + record.namespaceInitialAppKeyID = record.appKeyID + + let authorization = try DiagnosticsResponseProtocol.decode(golden.authorization, record: record) + #expect(authorization.type == .responseAuthorization) + #expect(authorization.digest == (try Data(m1Hex: m6.authorizationDigestHex))) + + let response = try DiagnosticsResponseProtocol.decode(golden.response, record: record) + #expect(response.type == .responseArtifact) + #expect(response.digest == (try Data(m1Hex: m6.responseDigestHex))) + + let upload = try M5UploadGoldenMessages.make(m5) + let goldenRequest = try DiagnosticsUploadProtocol.decode(upload.request, record: record) + let goldenQuery = try DiagnosticsUploadProtocol.decode(upload.query, record: record) + let operationID = try Data(m1Hex: m5.operationIdHex) + let installationBinding = DiagnosticsNamespaceProtocol.installationBinding( + initialAppKeyID: record.appKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + let operation = DiagnosticsUploadProtocol.Operation( + request: goldenRequest, + query: goldenQuery, + operationID: operationID, + installationBinding: installationBinding, + requestComponents: try DiagnosticsNamespaceProtocol.operationRequestComponents( + installationBinding: installationBinding, + operationID: operationID + ) + ) + let attestation = try DiagnosticsUploadProtocol.decode(upload.attestation, record: record) + + let produced = try DiagnosticsResponseProtocol.makeAuthorization( + record: record, + appKey: appKey, + operation: operation, + attestation: attestation, + authorizationNonce: try Data(m1Hex: m6.authorizationNonceHex), + now: Date(timeIntervalSince1970: TimeInterval(m6.authorizationIssuedAt)) + ) + // CryptoKit Ed25519 signatures are randomized, so the produced + // authorization matches the golden vector in body and digest (the + // exact signed field set) while carrying its own valid signature. + #expect(produced.body == authorization.body) + #expect(produced.digest == authorization.digest) + + let validated = try DiagnosticsResponseProtocol.validateResponseArtifact( + golden.response, + operation: operation, + attestation: attestation, + authorization: produced, + record: record, + now: Date(timeIntervalSince1970: TimeInterval(m6.responseIssuedAt)) + ) + #expect(validated.canonical == golden.response) + + for index in golden.response.indices { + var tampered = golden.response + tampered[index] ^= 0x01 + #expect(throws: (any Error).self) { + _ = try DiagnosticsResponseProtocol.validateResponseArtifact( + tampered, + operation: operation, + attestation: attestation, + authorization: produced, + record: record, + now: Date(timeIntervalSince1970: TimeInterval(m6.responseIssuedAt)) + ) + } + } + } + + @Test("Stale, tampered, generation-changed, cancelled, and restarted downloads never set evidence") + func downloadFailureBoundaries() async throws { + let identifier = UUID().uuidString.lowercased() + let support = FileManager.default.temporaryDirectory + .appendingPathComponent("vaultsync-download-support-\(identifier)", isDirectory: true) + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("vaultsync-download-folder-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.download.tests.\(identifier)", + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + try? FileManager.default.removeItem(at: folder) + } + _ = try store.installationCredential() + + let m5 = try M5UploadFixtureLoader.load() + let appSeed = try Data(m1Hex: m5.appSeedHex) + let helperKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(m1Hex: m5.helperSeedHex) + ) + var record = makeRuntimeRecord( + appSeed: appSeed, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: try Data(m1Hex: m5.homeserverBindingHex), + folder: try Data(m1Hex: m5.folderBindingHex), + folderID: "controlled-download", + appEpoch: m5.appEpoch, + helperEpoch: m5.helperEpoch + ) + let wall = Date(timeIntervalSince1970: TimeInterval(m5.requestIssuedAt)) + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: appSeed) + let enablement = try DiagnosticsNamespaceProtocol.makeEnablement( + record: record, + appKey: appKey, + nonce: Data(repeating: 0x19, count: 32), + now: wall + ) + let m4Fixture = try loadDiagnosticsHexFixture(named: "diagnostics-namespace-m4") + let goldenRoot = try DiagnosticsDeterministicCBOR.decode( + Data(m1Hex: #require(m4Fixture["02_root_manifest"])) + ) + let rootData = try makeNamespaceRoot( + enablement: enablement, + record: record, + helperKey: helperKey, + readmeDigest: try #require(goldenRoot.bytes(for: 29, count: 32)), + createdAt: m5.requestIssuedAt + 1 + ) + let root = try DiagnosticsNamespaceProtocol.validateRootManifest( + rootData, + enablement: enablement, + record: record + ) + let candidate = try DiagnosticsNamespaceProtocol.makeInitialAuthorization( + record: record, + root: root, + appKey: appKey, + nonce: Data(repeating: 0x30, count: 32), + now: wall.addingTimeInterval(2) + ) + let completed = try countersignInitialAuthorization(candidate.message, helperKey: helperKey) + let authorizationDigest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorization( + completed, + candidate: candidate, + record: record, + root: root + ) + record.state = .namespaceActive + record.lastOutgoing = candidate.message + record.lastIncoming = completed + record.namespaceID = root.namespaceID + record.namespaceInitialAppKeyID = record.appKeyID + record.namespaceEnablement = enablement + record.namespaceRootDigest = root.rootDigest + record.namespaceManifestDigest = root.manifestDigest + record.namespaceManifestEpoch = record.helperEpoch + record.namespaceAuthorizationDigest = authorizationDigest + record.namespaceAuthorizationEpoch = 1 + try store.save(record) + + let namespace = folder.appendingPathComponent( + DiagnosticsNamespaceProtocol.rootName, + isDirectory: true + ) + try FileManager.default.createDirectory(at: namespace, withIntermediateDirectories: true) + try rootData.write( + to: namespace.appendingPathComponent(DiagnosticsNamespaceProtocol.rootManifestName), + options: .atomic + ) + let authorizationRelative = try DiagnosticsNamespaceProtocol.authorizationRelativePath( + installationBinding: candidate.installationBinding + ) + let authorizationURL = namespace.appendingPathComponent(authorizationRelative) + try FileManager.default.createDirectory( + at: authorizationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try completed.write(to: authorizationURL, options: .atomic) + try FileManager.default.createDirectory( + at: namespace + .appendingPathComponent("installations", isDirectory: true) + .appendingPathComponent( + DiagnosticsNamespaceProtocol.base32LowerNoPadding(candidate.installationBinding), + isDirectory: true + ) + .appendingPathComponent("operations", isDirectory: true), + withIntermediateDirectories: true + ) + + let clock = LockedDiagnosticsClock(wall, continuous: 1_000) + let sharedRecord = record + let sharedFolder = folder + + func makePreflight(_ requireEmptySlot: Bool) -> DiagnosticsUploadPreflight { + DiagnosticsUploadPreflight( + folderID: sharedRecord.folderID, + folderPath: sharedFolder.path, + peerID: sharedRecord.homeserverDeviceID, + engineGeneration: 7, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [sharedRecord.homeserverDeviceID], + peerConnected: true, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: requireEmptySlot + ) + } + + func runScenario( + operationSeed: UInt8, + respond: (@Sendable (Data) async throws -> Void)?, + events: @escaping DiagnosticsPairingController.UploadEventsProvider + ) async -> (DiagnosticsPairingController, ForegroundUploadTransport, LockedUploadRequestBox) { + let requestBox = LockedUploadRequestBox() + let transport = ForegroundUploadTransport( + record: sharedRecord, + helperKey: helperKey, + clock: clock, + request: { requestBox.value() }, + acceptAfter: 1, + respond: respond + ) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() }, + uploadRandomBytes: { count in + if count == DiagnosticsUploadProtocol.payloadByteCount { + return Data(repeating: operationSeed &+ 3, count: count) + } + return Data(repeating: operationSeed &+ UInt8(truncatingIfNeeded: count % 7), count: count) + }, + uploadFileWriter: { path, components, data in + try DiagnosticsUploadFileStore.createImmutable( + folderPath: path, + components: components, + data: data + ) + requestBox.set(data) + }, + uploadSleep: { + clock.advance(by: TimeInterval($0)) + await Task.yield() + } + ) + controller.refresh() + await controller.checkCapability(recordID: sharedRecord.id) + controller.beginForegroundUpload( + recordID: sharedRecord.id, + preflight: { _, _, requireEmptySlot in makePreflight(requireEmptySlot) }, + rescan: { true }, + events: events + ) + return (controller, transport, requestBox) + } + + func waitTerminal(_ controller: DiagnosticsPairingController) async { + for _ in 0..<20_000 { + if let phase = controller.uploadStatuses[sharedRecord.id]?.phase, + ![.preflighting, .checking, .uploadObserved].contains(phase) { + return + } + await Task.yield() + } + Issue.record("controlled download did not reach a terminal state") + } + + // Scenario 1: a response and event that exist before the response + // baseline can never set download evidence — the operation times out + // as a partial result with upload preserved. + let staleBox = LockedDownloadEventBox() + staleBox.append(DiagnosticsResponseProtocol.DownloadEvent( + id: staleBox.nextID(), + type: "ItemFinished", + time: iso8601WithNanoseconds(clock.value().addingTimeInterval(-30)), + data: [ + "folder": sharedRecord.folderID, + "item": "stale-item-before-baseline", + "type": "file", + "action": "update", + "error": "", + ] + )) + let (staleController, staleTransport, _) = await runScenario( + operationSeed: 0x10, + respond: nil, + events: { sinceID in + DiagnosticsResponseProtocol.DownloadEventSnapshot( + generation: 7, + events: staleBox.events(after: sinceID) + ) + } + ) + await waitTerminal(staleController) + let stale = try #require(staleController.uploadStatuses[sharedRecord.id]) + #expect(stale.phase == .timedOut) + #expect(stale.evidence.uploadObserved) + #expect(!stale.evidence.downloadObserved) + #expect(stale.completedResponsePolls == DiagnosticsUploadProtocol.pollDelays.count) + let staleAuthorizations = await staleTransport.responseAuthorizations() + #expect(staleAuthorizations.count == 1) + + // Scenario 2: a fresh event pointing at a tampered artifact at the + // exact expected path is unexpected authenticated namespace content + // and terminates as conflict with upload preserved. + let tamperBox = LockedDownloadEventBox() + let tamperComponents = try DiagnosticsNamespaceProtocol.operationResponseComponents( + installationBinding: candidate.installationBinding, + operationID: Data(repeating: 0x20 &+ UInt8(truncatingIfNeeded: 32 % 7), count: 32) + ) + let tamperRelative = tamperComponents.joined(separator: "/") + let (tamperController, _, _) = await runScenario( + operationSeed: 0x20, + respond: { authorization in + let response = try makeHelperResponseArtifact( + authorization: authorization, + record: sharedRecord, + helperKey: helperKey, + now: clock.value(), + tamperSignature: true + ) + let url = tamperComponents.reduce(sharedFolder) { + $0.appendingPathComponent($1) + } + try response.write(to: url, options: .atomic) + tamperBox.append(DiagnosticsResponseProtocol.DownloadEvent( + id: tamperBox.nextID(), + type: "ItemFinished", + time: iso8601WithNanoseconds(clock.value().addingTimeInterval(0.5)), + data: [ + "folder": sharedRecord.folderID, + "item": tamperRelative, + "type": "file", + "action": "update", + "error": "", + ] + )) + }, + events: { sinceID in + DiagnosticsResponseProtocol.DownloadEventSnapshot( + generation: 7, + events: tamperBox.events(after: sinceID) + ) + } + ) + await waitTerminal(tamperController) + let tampered = try #require(tamperController.uploadStatuses[sharedRecord.id]) + #expect(tampered.phase == .conflict) + #expect(tampered.evidence.uploadObserved) + #expect(!tampered.evidence.downloadObserved) + + // Scenario 3: an engine-generation change between upload acceptance + // and the response baseline interrupts the operation. + let (generationController, _, _) = await runScenario( + operationSeed: 0x30, + respond: nil, + events: { _ in + DiagnosticsResponseProtocol.DownloadEventSnapshot(generation: 8, events: []) + } + ) + await waitTerminal(generationController) + let generation = try #require(generationController.uploadStatuses[sharedRecord.id]) + #expect(generation.phase == .interrupted) + #expect(generation.evidence.uploadObserved) + #expect(!generation.evidence.downloadObserved) + + // Scenario 4: explicit cancellation during the download leg is + // terminal with the upload evidence preserved. The transport blocks + // inside the authorization call until the test releases it. + let cancelGate = LockedUploadRequestBox() + let (cancelController, cancelTransport, _) = await runScenario( + operationSeed: 0x40, + respond: { _ in + while cancelGate.value() == nil { + await Task.yield() + } + }, + events: { _ in + DiagnosticsResponseProtocol.DownloadEventSnapshot(generation: 7, events: []) + } + ) + for _ in 0..<20_000 { + let held = await cancelTransport.responseAuthorizations() + if held.count == 1 { break } + await Task.yield() + } + cancelController.cancelForegroundUpload(recordID: sharedRecord.id) + cancelGate.set(Data([0x01])) + for _ in 0..<2_000 { await Task.yield() } + let cancelled = try #require(cancelController.uploadStatuses[sharedRecord.id]) + #expect(cancelled.phase == .cancelled) + #expect(cancelled.evidence.uploadObserved) + #expect(!cancelled.evidence.downloadObserved) + + // Scenario 5: a controller restart destroys the active correlation; + // nothing resumes and no late event can set evidence. + let (restartController, _, _) = await runScenario( + operationSeed: 0x50, + respond: nil, + events: { _ in + DiagnosticsResponseProtocol.DownloadEventSnapshot(generation: 7, events: []) + } + ) + for _ in 0..<50 { await Task.yield() } + restartController.refresh() + #expect(restartController.uploadStatuses[sharedRecord.id] == nil) + for _ in 0..<200 { await Task.yield() } + #expect(restartController.uploadStatuses[sharedRecord.id] == nil) + } +} diff --git a/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift index d65e588..1755fa5 100644 --- a/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift +++ b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift @@ -123,18 +123,51 @@ struct DiagnosticsForegroundUploadRuntimeTests { let clock = LockedDiagnosticsClock(wall, continuous: 1_000) let requestBox = LockedUploadRequestBox() + let responseComponents = try DiagnosticsNamespaceProtocol.operationResponseComponents( + installationBinding: candidate.installationBinding, + operationID: try Data(m1Hex: uploadFixture.operationIdHex) + ) + let responseRelativePath = responseComponents.joined(separator: "/") + let eventBox = LockedDownloadEventBox() + let happyRecord = record + let happyFolder = folder let transport = ForegroundUploadTransport( record: record, helperKey: helperKey, clock: clock, request: { requestBox.value() }, - acceptAfter: 2 + acceptAfter: 2, + respond: { authorization in + let response = try makeHelperResponseArtifact( + authorization: authorization, + record: happyRecord, + helperKey: helperKey, + now: clock.value() + ) + let url = responseComponents.reduce(happyFolder) { + $0.appendingPathComponent($1) + } + try response.write(to: url, options: .atomic) + eventBox.append(DiagnosticsResponseProtocol.DownloadEvent( + id: eventBox.nextID(), + type: "ItemFinished", + time: iso8601WithNanoseconds(clock.value().addingTimeInterval(0.5)), + data: [ + "folder": happyRecord.folderID, + "item": responseRelativePath, + "type": "file", + "action": "update", + "error": "", + ] + )) + } ) let random = LockedUploadRandom(values: [ try Data(m1Hex: uploadFixture.operationIdHex), try Data(m1Hex: uploadFixture.requestNonceHex), try Data(m1Hex: uploadFixture.queryNonceHex), try Data(m1Hex: uploadFixture.requestPayloadHex), + Data(repeating: 0x45, count: 32), ]) let controller = DiagnosticsPairingController( credentialStore: store, @@ -180,20 +213,29 @@ struct DiagnosticsForegroundUploadRuntimeTests { operationSlotEmpty: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: { sinceID in + DiagnosticsResponseProtocol.DownloadEventSnapshot( + generation: 7, + events: eventBox.events(after: sinceID) + ) + } ) await waitForTerminalUpload(controller: controller, recordID: record.id) let status = try #require(controller.uploadStatuses[record.id]) - #expect(status.phase == .uploadObserved) + #expect(status.phase == .downloadObserved) #expect(status.evidence.uploadObserved) - #expect(!status.evidence.downloadObserved) + #expect(status.evidence.downloadObserved) #expect(!status.evidence.roundtripConfirmed) #expect(status.completedPolls == 2) + #expect(status.completedResponsePolls == 1) let queries = await transport.uploadQueries() #expect(queries.count == 2) #expect(queries[0] == queries[1]) #expect(requestBox.value() != nil) + let authorizations = await transport.responseAuthorizations() + #expect(authorizations.count == 1) let lateRequestBox = LockedUploadRequestBox() let lateTransport = ForegroundUploadTransport( @@ -228,7 +270,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForHeldResponse(lateTransport) #expect(await lateTransport.isHoldingAcceptedResponse()) @@ -273,7 +316,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForHeldResponse(restartTransport) restartController.refresh() @@ -317,7 +361,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForHeldResponse(racedTransport) let racedComponents = try DiagnosticsNamespaceProtocol.operationRequestComponents( @@ -344,7 +389,7 @@ struct DiagnosticsForegroundUploadRuntimeTests { ) var rateValues: [Data] = [] for operation in 0..<4 { - let first = UInt8(0x61 + operation * 4) + let first = UInt8(0x61 + operation * 5) rateValues.append(Data(repeating: first, count: 32)) rateValues.append(Data(repeating: first + 1, count: 32)) rateValues.append(Data(repeating: first + 2, count: 32)) @@ -352,6 +397,7 @@ struct DiagnosticsForegroundUploadRuntimeTests { repeating: first + 3, count: DiagnosticsUploadProtocol.payloadByteCount )) + rateValues.append(Data(repeating: first + 4, count: 32)) } let rateController = makeUploadController( store: store, @@ -361,8 +407,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requestBox: rateRequestBox ) rateController.refresh() - await rateController.checkCapability(recordID: record.id) for _ in 0..<3 { + await rateController.checkCapability(recordID: record.id) rateController.beginForegroundUpload( recordID: record.id, preflight: { _, _, requireEmptySlot in @@ -372,12 +418,17 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForTerminalUpload(controller: rateController, recordID: record.id) - #expect(rateController.uploadStatuses[record.id]?.phase == .uploadObserved) + let rateStatus = rateController.uploadStatuses[record.id] + #expect(rateStatus?.phase == .timedOut) + #expect(rateStatus?.evidence.uploadObserved == true) + #expect(rateStatus?.evidence.downloadObserved == false) } #expect(rateRequestBox.count() == 3) + await rateController.checkCapability(recordID: record.id) rateController.beginForegroundUpload( recordID: record.id, preflight: { _, _, requireEmptySlot in @@ -387,7 +438,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForTerminalUpload(controller: rateController, recordID: record.id) #expect(rateController.uploadStatuses[record.id]?.phase == .rateLimited) @@ -426,7 +478,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { requireEmptySlot: requireEmptySlot ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForTerminalUpload(controller: timeoutController, recordID: record.id) let timeout = try #require(timeoutController.uploadStatuses[record.id]) @@ -485,7 +538,8 @@ struct DiagnosticsForegroundUploadRuntimeTests { operationSlotEmpty: valid.operationSlotEmpty ) }, - rescan: { true } + rescan: { true }, + events: self.emptyDownloadEvents ) await waitForTerminalUpload(controller: rejectedController, recordID: record.id) #expect(rejectedController.uploadStatuses[record.id]?.phase == .unsupported) @@ -575,9 +629,9 @@ struct DiagnosticsForegroundUploadRuntimeTests { controller: DiagnosticsPairingController, recordID: String ) async { - for _ in 0..<1_000 { + for _ in 0..<10_000 { if let phase = controller.uploadStatuses[recordID]?.phase, - ![.preflighting, .checking].contains(phase) { + ![.preflighting, .checking, .uploadObserved].contains(phase) { return } await Task.yield() @@ -637,6 +691,12 @@ struct DiagnosticsForegroundUploadRuntimeTests { ) } + private func emptyDownloadEvents( + _ sinceID: Int64 + ) -> DiagnosticsResponseProtocol.DownloadEventSnapshot? { + DiagnosticsResponseProtocol.DownloadEventSnapshot(generation: 7, events: []) + } + private func waitForHeldResponse(_ transport: ForegroundUploadTransport) async { for _ in 0..<1_000 { if await transport.isHoldingAcceptedResponse() { return } @@ -654,7 +714,92 @@ struct DiagnosticsForegroundUploadRuntimeTests { } } -private final class LockedUploadRequestBox: @unchecked Sendable { +final class LockedDownloadEventBox: @unchecked Sendable { + private let lock = NSLock() + private var events: [DiagnosticsResponseProtocol.DownloadEvent] = [] + private var lastID: Int64 = 0 + + func nextID() -> Int64 { + lock.lock() + defer { lock.unlock() } + lastID += 1 + return lastID + } + + func append(_ event: DiagnosticsResponseProtocol.DownloadEvent) { + lock.lock() + events.append(event) + lock.unlock() + } + + func events(after id: Int64) -> [DiagnosticsResponseProtocol.DownloadEvent] { + lock.lock() + defer { lock.unlock() } + return events.filter { $0.id > id } + } +} + +func iso8601WithNanoseconds(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) +} + +func makeHelperResponseArtifact( + authorization authorizationBytes: Data, + record: DiagnosticsPairingRecord, + helperKey: Curve25519.Signing.PrivateKey, + now: Date, + payload: Data? = nil, + nonce: Data = Data(repeating: 0x66, count: 32), + tamperSignature: Bool = false +) throws -> Data { + let authorization = try DiagnosticsResponseProtocol.decode(authorizationBytes, record: record) + guard let operationID = authorization.value.bytes(for: 11, count: 32), + let requestDigest = authorization.value.bytes(for: 17, count: 32), + let attestationDigest = authorization.value.bytes(for: 20, count: 32), + let expiry = authorization.value.unsigned(for: 13) else { + throw DiagnosticsProtocolError.invalidMessage + } + let issued = UInt64(now.timeIntervalSince1970.rounded(.down)) + let responsePayload = payload + ?? Data(repeating: 0x77, count: DiagnosticsUploadProtocol.payloadByteCount) + let value = DiagnosticsCBORValue.map([ + DiagnosticsCBORField(label: 1, value: .text(DiagnosticsUploadProtocol.capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(7)), + DiagnosticsCBORField(label: 5, value: .bytes(record.homeserverBinding)), + DiagnosticsCBORField(label: 6, value: .bytes(record.folderBinding)), + DiagnosticsCBORField(label: 7, value: .bytes(record.appKeyID)), + DiagnosticsCBORField(label: 8, value: .bytes(record.helperKeyID)), + DiagnosticsCBORField(label: 9, value: .unsigned(record.appEpoch)), + DiagnosticsCBORField(label: 10, value: .unsigned(record.helperEpoch)), + DiagnosticsCBORField(label: 11, value: .bytes(operationID)), + DiagnosticsCBORField(label: 12, value: .unsigned(issued)), + DiagnosticsCBORField(label: 13, value: .unsigned(expiry)), + DiagnosticsCBORField(label: 17, value: .bytes(requestDigest)), + DiagnosticsCBORField(label: 20, value: .bytes(attestationDigest)), + DiagnosticsCBORField(label: 22, value: .bytes(authorization.digest)), + DiagnosticsCBORField(label: 23, value: .bytes(nonce)), + DiagnosticsCBORField(label: 24, value: .bytes(responsePayload)), + DiagnosticsCBORField(label: 25, value: .bytes(DiagnosticsCrypto.sha256(responsePayload))), + ]) + let body = try DiagnosticsDeterministicCBOR.encode(value) + var input = Data("eu.vaultsync.roundtrip/v1/response-artifact\0".utf8) + input.append(body) + var signature = try helperKey.signature(for: input) + if tamperSignature { + signature[0] ^= 0x01 + } + guard case .map(var fields) = value else { + throw DiagnosticsProtocolError.invalidMessage + } + fields.append(DiagnosticsCBORField(label: 255, value: .bytes(signature))) + return try DiagnosticsDeterministicCBOR.encode(.map(fields)) +} + +final class LockedUploadRequestBox: @unchecked Sendable { private let lock = NSLock() private var request: Data? private var writes = 0 @@ -679,7 +824,7 @@ private final class LockedUploadRequestBox: @unchecked Sendable { } } -private final class LockedUploadRandom: @unchecked Sendable { +final class LockedUploadRandom: @unchecked Sendable { private let lock = NSLock() private var values: [Data] @@ -697,14 +842,16 @@ private final class LockedUploadRandom: @unchecked Sendable { } } -private actor ForegroundUploadTransport: DiagnosticsTransporting { +actor ForegroundUploadTransport: DiagnosticsTransporting { private let record: DiagnosticsPairingRecord private let helperKey: Curve25519.Signing.PrivateKey private let clock: LockedDiagnosticsClock private let request: @Sendable () -> Data? private let acceptAfter: Int private let holdAcceptedResponse: Bool + private let respond: (@Sendable (Data) async throws -> Void)? private var queries: [Data] = [] + private var authorizations: [Data] = [] private var acceptedResponseContinuation: CheckedContinuation? private var acceptedResponseReleased = false private var holdingAcceptedResponse = false @@ -716,7 +863,8 @@ private actor ForegroundUploadTransport: DiagnosticsTransporting { clock: LockedDiagnosticsClock, request: @escaping @Sendable () -> Data?, acceptAfter: Int, - holdAcceptedResponse: Bool = false + holdAcceptedResponse: Bool = false, + respond: (@Sendable (Data) async throws -> Void)? = nil ) { self.record = record self.helperKey = helperKey @@ -724,6 +872,7 @@ private actor ForegroundUploadTransport: DiagnosticsTransporting { self.request = request self.acceptAfter = acceptAfter self.holdAcceptedResponse = holdAcceptedResponse + self.respond = respond } func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { @@ -731,6 +880,11 @@ private actor ForegroundUploadTransport: DiagnosticsTransporting { case DiagnosticsCapabilityProtocol.path: guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } return try makeCapabilityResponseForQuery(body, helperKey: helperKey) + case DiagnosticsResponseProtocol.path: + guard !responseBody else { throw DiagnosticsProtocolError.invalidMessage } + authorizations.append(body) + try await respond?(body) + return nil case DiagnosticsUploadProtocol.path: guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } queries.append(body) @@ -809,6 +963,8 @@ private actor ForegroundUploadTransport: DiagnosticsTransporting { func uploadQueries() -> [Data] { queries } + func responseAuthorizations() -> [Data] { authorizations } + func isHoldingAcceptedResponse() -> Bool { holdingAcceptedResponse } func releaseAcceptedResponse() { diff --git a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift index 19bc664..baa8e6b 100644 --- a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift +++ b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift @@ -346,9 +346,13 @@ struct DiagnosticsUploadM5Tests { ] where body.contains(uploadDomain) { #expect(url.lastPathComponent == "DiagnosticsUploadProtocol.swift") } - for laterDomain in [ + for responseDomain in [ "eu.vaultsync.roundtrip/v1/response-authorization", "eu.vaultsync.roundtrip/v1/response-artifact", + ] where body.contains(responseDomain) { + #expect(url.lastPathComponent == "DiagnosticsResponseProtocol.swift") + } + for laterDomain in [ "eu.vaultsync.roundtrip/v1/cleanup-request", "eu.vaultsync.roundtrip/v1/cleanup-ack", ] { @@ -359,6 +363,7 @@ struct DiagnosticsUploadM5Tests { "DiagnosticsUploadPreflight.swift", "DiagnosticsUploadFileStore.swift", "DiagnosticsPairingController.swift", + "DiagnosticsResponseProtocol.swift", ].contains(url.lastPathComponent) { for forbiddenSink in [ "UserDefaults", "Keychain", "StoreKit", "APNs", "Cloud Relay", diff --git a/notify/diagnostics_contract_model_test.go b/notify/diagnostics_contract_model_test.go index 2c211e2..313db4c 100644 --- a/notify/diagnostics_contract_model_test.go +++ b/notify/diagnostics_contract_model_test.go @@ -293,6 +293,7 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) appPairingProtocolCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsPairingProtocol.swift") appCapabilityNamespaceCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsCapabilityNamespaceProtocol.swift") appUploadProtocolCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsUploadProtocol.swift") + appResponseProtocolCarrier := filepath.Join(repoRoot, "ios", "VaultSync", "Services", "DiagnosticsResponseProtocol.swift") runtimeRoots := []string{ filepath.Join(repoRoot, "notify"), filepath.Join(repoRoot, "ios", "VaultSync"), @@ -332,7 +333,9 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) ((name == "roundtrip.capability_query" || name == "roundtrip.capability_response") && path == appCapabilityNamespaceCarrier) || ((name == "roundtrip.operation_request" || name == "roundtrip.attestation_query" || - name == "roundtrip.upload_attestation") && path == appUploadProtocolCarrier) + name == "roundtrip.upload_attestation") && path == appUploadProtocolCarrier) || + ((name == "roundtrip.response_authorization" || name == "roundtrip.response_artifact") && + path == appResponseProtocolCarrier) if bytes.Contains(body, []byte(strings.TrimSuffix(domain, "\x00"))) && !allowed { return fmt.Errorf("runtime file %s contains an unapproved signature domain", path) } diff --git a/notify/diagnostics_download_syncthing_e2e_test.go b/notify/diagnostics_download_syncthing_e2e_test.go new file mode 100644 index 0000000..cbdc3ca --- /dev/null +++ b/notify/diagnostics_download_syncthing_e2e_test.go @@ -0,0 +1,120 @@ +//go:build linux && diagnostics_m5_syncthing_e2e + +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +// TestDiagnosticsDownloadThroughTwoEphemeralSyncthingInstances proves the M6 +// response leg over the real transport: the exact upload chain propagates from +// the app namespace to the helper, the real helper response foundation creates +// the signed response artifact only from a valid authorization, and that exact +// artifact propagates back to the app namespace byte-identically where the full +// D024 chain validates. Download acceptance itself (fresh ItemFinished plus +// baseline gates) is the app runtime's claim and is proven in the Swift suite. +func TestDiagnosticsDownloadThroughTwoEphemeralSyncthingInstances(t *testing.T) { + binary := os.Getenv("VAULTSYNC_M5_SYNCTHING_BIN") + if binary == "" { + t.Skip("explicit local Syncthing test binary not provided") + } + if !filepath.IsAbs(binary) { + t.Fatal("local Syncthing test binary must use an absolute path") + } + info, err := os.Stat(binary) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 { + t.Fatal("local Syncthing test binary is unavailable") + } + + prepared := prepareDiagnosticsNamespaceLinuxFixture(t) + defer prepared.handle.Close() + helperParent := filepath.Join(t.TempDir(), "helper-vault") + if err := os.Mkdir(helperParent, 0o700); err != nil || os.Mkdir(filepath.Join(helperParent, ".stfolder"), 0o700) != nil { + t.Fatal("create isolated helper folder") + } + + appSyncthing := startDiagnosticsM5Syncthing(t, binary, "app") + helperSyncthing := startDiagnosticsM5Syncthing(t, binary, "helper") + defer appSyncthing.stop() + defer helperSyncthing.stop() + configureDiagnosticsM5Syncthing(t, appSyncthing, helperSyncthing, prepared.parentPath) + configureDiagnosticsM5Syncthing(t, helperSyncthing, appSyncthing, helperParent) + waitForDiagnosticsM5SyncthingConnection(t, appSyncthing, helperSyncthing.deviceID) + waitForDiagnosticsM5SyncthingConnection(t, helperSyncthing, appSyncthing.deviceID) + + helperRootPath := filepath.Join(helperParent, diagnosticsNamespaceRootName) + helperHandle := waitForDiagnosticsM5Namespace(t, helperRootPath) + defer helperHandle.Close() + helperPrepared := prepared + helperPrepared.parentPath = helperParent + helperPrepared.rootPath = helperRootPath + helperPrepared.handle = helperHandle + + fixture := loadDiagnosticsUploadGoldenFixture(t) + golden := generateDiagnosticsResponseGoldenMessages(t, fixture) + + // The app-authored request and the helper attestation reach the helper + // runtime only through the synchronized namespace. + requestPath := diagnosticsUploadOperationPath(t, fixture, 1) + attestationPath := diagnosticsUploadOperationPath(t, fixture, 2) + responsePath := diagnosticsUploadOperationPath(t, fixture, 3) + if _, err := prepared.handle.CreateImmutable(requestPath, golden.upload.request.canonical); err != nil { + t.Fatal("create exact app-authored request") + } + if _, err := prepared.handle.CreateImmutable(attestationPath, golden.upload.attestation.canonical); err != nil { + t.Fatal("create exact attestation artifact") + } + waitForDiagnosticsM5Immutable(t, helperHandle, requestPath, golden.upload.request.canonical) + waitForDiagnosticsM5Immutable(t, helperHandle, attestationPath, golden.upload.attestation.canonical) + + // The real helper response foundation, reconstructing solely from its + // synchronized namespace, accepts the exact authorization and creates the + // one signed response artifact. + foundation := newDiagnosticsResponseGoldenFoundation( + t, helperPrepared, fixture, diagnosticsResponseGoldenRandom(golden), + func() time.Time { return time.Unix(int64(diagnosticsResponseGoldenResponseIssuedAt), 0) }, + nil, newDiagnosticsUploadCoordinator(), + ) + result := foundation.authorizeResponse(golden.authorization.canonical) + if result.disposition != diagnosticsResponseAccepted || result.reason != diagnosticsResponseReasonNone { + t.Fatalf("helper authorization result = %#v", result) + } + persisted, _, err := helperHandle.ReadImmutable(responsePath) + if err != nil || !bytes.Equal(persisted, golden.response.canonical) { + t.Fatalf("helper-side response artifact = %v", err) + } + + // The exact helper-authored bytes must become readable in the app + // namespace through Syncthing and validate through the full D024 chain. + synced := waitForDiagnosticsM5Immutable(t, prepared.handle, responsePath, golden.response.canonical) + response, decodeErr := decodeDiagnosticsResponseMessage(synced, golden.upload.context) + if decodeErr != nil { + t.Fatal("decode synchronized response artifact") + } + if chainErr := validateDiagnosticsResponseArtifactChain( + golden.upload.request, golden.upload.attestation, golden.authorization, response, + ); chainErr != nil { + t.Fatalf("synchronized response failed the causal chain: %v", chainErr) + } + + // A helper restart replays idempotently and never rewrites the artifact. + restarted := newDiagnosticsResponseGoldenFoundation( + t, helperPrepared, fixture, bytes.NewReader(bytes.Repeat([]byte{0xee}, 288)), + func() time.Time { return time.Unix(int64(diagnosticsResponseGoldenResponseIssuedAt+1), 0) }, + nil, newDiagnosticsUploadCoordinator(), + ) + if replay := restarted.authorizeResponse(golden.authorization.canonical); replay.disposition != diagnosticsResponseAccepted { + t.Fatalf("idempotent helper replay = %#v", replay) + } + replayed, _, err := helperHandle.ReadImmutable(responsePath) + if err != nil || !bytes.Equal(replayed, golden.response.canonical) { + t.Fatal("helper replay changed the persisted response artifact") + } + if err := prepared.handle.ScanFixedLayout(); err != nil || helperHandle.ScanFixedLayout() != nil { + t.Fatal("synchronized namespaces were not exact after the response leg") + } +} From 3b8d827c5dc104f3ff9506d4fa3bedcc6f97124c Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 10:56:18 +0200 Subject: [PATCH 2/3] Document controlled download boundaries Record the M6 evidence boundary: separate upload and download fields, partial semantics after an accepted upload, retained opaque response copies, unchanged helper 2.0.2 wire surface, and the owner-approved physical-device waiver with simulator plus isolated Syncthing substitute evidence. Roundtrip remains unset and VaultSync 2.0 NO-GO. --- PRIVACY.md | 30 +++++--- docs/architecture.md | 54 +++++++++------ docs/m6-controlled-download-readiness.md | 88 ++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 docs/m6-controlled-download-readiness.md diff --git a/PRIVACY.md b/PRIVACY.md index 587bb17..3650bd2 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -133,10 +133,13 @@ digest, opaque binding, nonce, transcript fingerprint, signed body, namespace path, mount alias, operation value, or artifact name. It creates no diagnostics telemetry, crash annotation, support-bundle export, Cloud Relay/APNs/StoreKit call, discovery request, trust adoption, share, rescan, or Syncthing -configuration/ignore change. No response has been accepted after a fresh local -apply on an iPhone. The unreleased app source can set only upload evidence after -an exact pinned helper attestation; controlled download and roundtrip evidence -remain unset. +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. 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 @@ -256,17 +259,26 @@ on at most eight bounded polls to the fixed TLS-1.3/SPKI-pinned helper endpoint. Only the exact paired-helper signature and complete Decision 024 binding for that active request/query can set the separate in-memory `upload observed` field. HTTP status, reachability, request creation, rescan, timestamps, index or -idle state, and a synchronized attestation copy cannot set it. Download and -roundtrip fields remain false and cannot be inferred from upload. +idle state, and a synchronized attestation copy cannot set it. After an accepted +upload the same operation captures a fresh event-cursor, wall-clock, and +engine-generation baseline, sends one signed response authorization over the +pinned endpoint, and sets the separate `download observed` field only after a +fresh successful local apply of the exact expected response path plus complete +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. The active operation, request/query bytes, random values, digests, poll state, and evidence are not persisted in preferences, Keychain, logs, telemetry, crash reports, support bundles, Relay, APNs, StoreKit, or pairing records. Leaving the view, cancellation, refresh, app/engine restart, target or credential change, timeout, or conflict ends the operation, and a late response -cannot upgrade it. The request and helper attestation are synchronized opaque -files and may remain in live folders, peers, backups, versions, conflict copies, -remote history, deletion records, or tombstones. Expiry, app rollback, or live +cannot upgrade it. The request, helper attestation, and helper response are +synchronized opaque files and may remain in live folders, peers, backups, +versions, conflict copies, remote history, deletion records, or tombstones. Expiry, app rollback, or live cleanup does not promise removal of those retained copies; they never regain validity or become evidence. diff --git a/docs/architecture.md b/docs/architecture.md index 16ca5cf..625ad94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,17 +47,18 @@ succeeded” flag: | Background sync started | This iPhone, unattributed | Entry into the silent-push sync path | | 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 | Not implemented in the app; 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 controlled download from the same active chain. | +| 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. | None automatically implies the next. Relay reachability is not a trigger; trigger observation is not APNs delivery; push receipt is not background start; engine reachability, scans, index updates, `idle`, and 100% completion are not 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 is a separate, -explicitly initiated Decision 024 field in unreleased source. Controlled download -remains independent and unset, so a full roundtrip cannot be derived. +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. Server snapshots contain only entitlement, provisioning, backend, and per-homeserver Relay observation. The v1 push contains no homeserver/folder @@ -86,21 +87,21 @@ 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 upload-only -operation below and cannot populate that operation's evidence. Controlled -download and roundtrip require their later Decision 024 milestones. Relay v1 is -unchanged. +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. -#### Opt-in correlated-roundtrip helper runtime — foreground upload only +#### Opt-in correlated-roundtrip helper runtime — upload and controlled download [Decisions 021–024](decisions/021-capability-negotiated-helper-contract-for-correlated-roundtrip-proof.md) define the proof and rollout boundaries. Helper 2.0.2 is published and its 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. Product upload is -implemented only to the exact signed-attestation boundary and remains -unreleased; controlled download and causal roundtrip are unset. VaultSync 2.0 +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. One upload operation begins only after a user tap and a second localized @@ -120,7 +121,17 @@ gate valid for the still-active tuple, sets `upload observed`. HTTP status, request creation, rescan, index/idle/completion state, timestamps, and a synchronized attestation copy cannot do so. Cancellation, view exit, refresh, app/engine restart, target or credential change, timeout, and conflict are -terminal; late responses never upgrade them. Download and roundtrip remain +terminal; late responses never upgrade them. + +After upload acceptance the same operation captures a fresh event-cursor, +wall-clock, and engine-generation baseline, sends one signed type-6 response +authorization over the pinned endpoint, and watches only the exact expected +response path. A fresh successful local apply plus complete type-7 signature, +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. The runtime is gated by an operator-authored read-only configuration plus a @@ -214,12 +225,15 @@ namespace, mappings, backups, versions, conflict copies, and tombstones; an old helper yields capability unavailable and never a weaker success. The helper can reconstruct an exact authorized runtime session and process the -existing D024 foundations. The unreleased M5 app can use only its capability and -upload-attestation paths, but signatures establish only authorship and causal -bindings—not transport route, exact network bytes, direct peer, block -provenance, future delivery, or global sync health. No response has passed a -fresh post-authorization iPhone cursor/nanosecond/generation/`ItemFinished` -baseline. Cleanup remains evidence-orthogonal. Helper-first publication, +existing D024 foundations. The unreleased app can use only its capability, +upload-attestation, and response-authorization paths, but signatures establish +only authorship and causal bindings—not transport route, exact network bytes, +direct peer, block provenance, future delivery, or global sync health. The +fresh post-authorization cursor/wall-clock/generation/`ItemFinished` download +gate exists in unreleased source and has been exercised only with injected +event streams plus byte-exact artifacts from isolated local Syncthing +instances; no response has passed it on a physical iPhone. Cleanup remains +evidence-orthogonal. Helper-first publication, production rollout, rollback, the M5 real-device/PR gate, and the later download and roundtrip app milestones remain mandatory. See [helper runtime and packaging readiness](helper-runtime-packaging-readiness.md). diff --git a/docs/m6-controlled-download-readiness.md b/docs/m6-controlled-download-readiness.md new file mode 100644 index 0000000..40202a9 --- /dev/null +++ b/docs/m6-controlled-download-readiness.md @@ -0,0 +1,88 @@ +# M6 controlled-download readiness + +**Status:** Unreleased app source. The controlled download leg of +[Decision 024](decisions/024-canonical-correlated-roundtrip-contract-and-threat-model.md) +is implemented against the published, unchanged helper 2.0.2 runtime. Causal +roundtrip remains unset and is a later, separately gated milestone. VaultSync +2.0 remains NO-GO. + +## Scope + +One explicit user tap with localized confirmation starts one operation for one +paired app/homeserver/folder/helper tuple. The upload leg is unchanged from the +M5 readiness boundary. This milestone adds only the app-side response leg: + +- a signed type-6 response authorization is created only after the exact + type-5 upload attestation was accepted for the still-active operation; +- the authorization is sent once over the fixed TLS-1.3/SPKI-pinned endpoint + `POST /api/v1/diagnostics/authorize-response`; the 202 acknowledgement is + transport diagnostics only and never evidence; +- before the authorization is sent, the app captures a fresh response baseline: + the current bridge event cursor, the wall clock, and the engine generation + from the exact preflight boundary; +- `download observed` is set only after a fresh successful `ItemFinished` + apply of the exact expected response path — newer than the cursor and + wall-clock baselines inside the unchanged engine generation — followed by a + complete read of that exact file and full canonical, signature, key, epoch, + binding, operation, digest, nonce, payload, and TTL validation of the type-7 + response artifact against the active request, attestation, and + authorization; +- upload and download are separate evidence fields; roundtrip remains + immutable false; cleanup stays evidence-orthogonal. + +## Failure semantics + +A response that exists before the baseline or authorization, arrives after an +engine restart or generation change, appears at any other path, or fails any +validation can never set download evidence. Invalid bytes at the exact +expected namespace path terminate the operation as a conflict. Cancellation, +view exit, refresh, app or engine restart, binding or credential change, +timeout, and rate limits stay terminal; after an accepted upload every +terminal outcome preserves the upload field and is presented as a partial +result that no late artifact can upgrade. An app restart destroys the active +correlation and nothing resumes. + +## Compatibility and rollback + +The helper wire surface is unchanged: helper 2.0.2 already implements the +dormant response foundation, and no helper, Relay, Trigger v1, APNs, or +StoreKit change ships with this milestone. 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. The +request, attestation, and response artifacts are synchronized opaque files +and may remain in peers, backups, versions, conflict copies, and tombstones; +retained copies never regain validity. + +## Verification + +All Xcode results and derived data are outside the repository under `/tmp`. +The local gate for this milestone includes: + +- production Swift response protocol bound to the cross-language + `diagnostics-response-m6.json` golden vectors, including full-chain + validation and per-byte tamper rejection; +- the M6 controlled-download runtime suite: stale pre-baseline responses, + tampered artifacts at the exact path, engine-generation changes, + cancellation during the download leg, and restart non-resumption never set + evidence, while the exact fresh chain sets upload then download; +- the M5 foreground upload runtime suite re-run with the download leg, + including partial-result preservation and rate limiting; +- `TestDiagnosticsDownloadThroughTwoEphemeralSyncthingInstances` in the + isolated no-network Linux container: the exact upload chain propagates + app→helper through real Syncthing, the real helper response foundation + creates the one signed response artifact, those exact bytes propagate + helper→app and validate through the full D024 chain, and a helper restart + replays idempotently without rewriting the artifact; +- 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. The next milestone may +derive the causal roundtrip only from this operation's upload and download +legs after this PR and its review/CI gates complete. From 7886d39eec5b5d2123ac685f321d0776fb164163 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 11:27:37 +0200 Subject: [PATCH 3/3] Read events before the engine generation An engine restart between the two bridge reads would tag new-engine events with the pre-restart generation and falsely pass the download continuity check. Reading events first makes the generation a valid witness: any restart before or during the event fetch fails the caller's boundary check instead. --- ios/VaultSync/Views/ControlledDiagnosticsView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift index a7507a7..b5fd11d 100644 --- a/ios/VaultSync/Views/ControlledDiagnosticsView.swift +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -537,9 +537,15 @@ struct ControlledDiagnosticsView: View { syncthingManager.rescanFolder(id: record.folderID) == nil }, events: { sinceID in - DiagnosticsResponseProtocol.eventSnapshot( - generation: SyncBridgeService.eventStreamGeneration(), - json: SyncBridgeService.getEventsSince(lastID: Int(sinceID)) + // Read events before the generation: if the engine restarts + // in between, the newer generation fails the caller's + // continuity check instead of tagging new-engine events with + // the pre-restart generation. + let json = SyncBridgeService.getEventsSince(lastID: Int(sinceID)) + let generation = SyncBridgeService.eventStreamGeneration() + return DiagnosticsResponseProtocol.eventSnapshot( + generation: generation, + json: json ) } )