From 49cb0f057e3a400bc7597c3e78fbcfe9dfe40be8 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 02:47:30 +0200 Subject: [PATCH 1/4] Implement foreground upload attestation Add the explicit upload-only D024 app runtime with exact target, namespace, filesystem, polling, rate, and lifecycle gates. Bind product tests to cross-language vectors, terminal-state races, real Syncthing preflight, and upload-only evidence. --- go/bridge/diagnostics.go | 113 +++ go/bridge/folderstatus_test.go | 55 ++ ...agnosticsCapabilityNamespaceProtocol.swift | 23 +- .../DiagnosticsPairingController.swift | 426 ++++++++- .../Services/DiagnosticsPinnedTransport.swift | 3 +- .../Services/DiagnosticsUploadFileStore.swift | 100 +++ .../Services/DiagnosticsUploadPreflight.swift | 117 +++ .../Services/DiagnosticsUploadProtocol.swift | 378 ++++++++ .../Services/SyncBridgeService.swift | 20 + .../Views/ControlledDiagnosticsView.swift | 123 ++- ios/VaultSync/de.lproj/Localizable.strings | 19 + ios/VaultSync/en.lproj/Localizable.strings | 19 + ios/VaultSync/es.lproj/Localizable.strings | 19 + .../zh-Hans.lproj/Localizable.strings | 19 + .../DiagnosticsAppRuntimeM3Tests.swift | 14 +- ...gnosticsForegroundUploadRuntimeTests.swift | 822 ++++++++++++++++++ .../DiagnosticsUploadM5Tests.swift | 180 +++- notify/diagnostics_contract_model_test.go | 7 +- 18 files changed, 2435 insertions(+), 22 deletions(-) create mode 100644 go/bridge/diagnostics.go create mode 100644 ios/VaultSync/Services/DiagnosticsUploadFileStore.swift create mode 100644 ios/VaultSync/Services/DiagnosticsUploadPreflight.swift create mode 100644 ios/VaultSync/Services/DiagnosticsUploadProtocol.swift create mode 100644 ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift diff --git a/go/bridge/diagnostics.go b/go/bridge/diagnostics.go new file mode 100644 index 0000000..3927641 --- /dev/null +++ b/go/bridge/diagnostics.go @@ -0,0 +1,113 @@ +package bridge + +import ( + "path/filepath" + "strings" + + stfs "github.com/syncthing/syncthing/lib/fs" + "github.com/syncthing/syncthing/lib/ignore" +) + +const diagnosticsNamespaceRoot = "VaultSync Diagnostics" + +// DiagnosticsUploadPathAvailable performs the app-side filesystem and ignore +// preflight for one exact D024 upload request. It does not create directories, +// alter ignores, rescan, or otherwise mutate Syncthing configuration. +// +// The two components are opaque lowercase base32 encodings. Keeping the path +// construction here fixed prevents a caller-controlled relative path from +// crossing the gomobile boundary. +func DiagnosticsUploadPathAvailable(folderID, installationComponent, operationComponent string) bool { + return diagnosticsUploadPathVerdict(folderID, installationComponent, operationComponent, true) +} + +// DiagnosticsUploadPathAllowed rechecks the same fixed directories and real +// ignore matcher after the app has exclusively created its request. Existing +// operation artifacts are expected at that point and are verified separately +// by their canonical signed bytes. +func DiagnosticsUploadPathAllowed(folderID, installationComponent, operationComponent string) bool { + return diagnosticsUploadPathVerdict(folderID, installationComponent, operationComponent, false) +} + +func diagnosticsUploadPathVerdict( + folderID, installationComponent, operationComponent string, + requireEmpty bool, +) bool { + folders := getFolderConfigs() + if folders == nil { + return false + } + folder, ok := folders[folderID] + if !ok || !diagnosticsComponentValid(installationComponent) || + !diagnosticsComponentValid(operationComponent) { + return false + } + return diagnosticsUploadPathAvailable( + folder.Filesystem(), + installationComponent, + operationComponent, + requireEmpty, + ) +} + +func diagnosticsUploadPathAvailable( + filesystem stfs.Filesystem, + installationComponent, operationComponent string, + requireEmpty bool, +) bool { + if filesystem == nil || !diagnosticsComponentValid(installationComponent) || + !diagnosticsComponentValid(operationComponent) { + return false + } + + directories := []string{ + diagnosticsNamespaceRoot, + filepath.Join(diagnosticsNamespaceRoot, "installations"), + filepath.Join(diagnosticsNamespaceRoot, "installations", installationComponent), + filepath.Join(diagnosticsNamespaceRoot, "installations", installationComponent, "operations"), + } + for _, path := range directories { + info, err := filesystem.Lstat(path) + if err != nil || !info.IsDir() || info.IsSymlink() { + return false + } + } + + base := filepath.Join(directories[len(directories)-1], operationComponent) + artifacts := []string{ + base + ".request.cbor", + base + ".attestation.cbor", + base + ".response.cbor", + } + if requireEmpty { + for _, path := range artifacts { + if _, err := filesystem.Lstat(path); err == nil || !stfs.IsNotExist(err) { + return false + } + } + } + + matcher := ignore.New(filesystem) + defer matcher.Stop() + if err := matcher.Load(".stignore"); err != nil && !stfs.IsNotExist(err) { + return false + } + for _, path := range append(directories, artifacts...) { + if matcher.Match(path).IsIgnored() { + return false + } + } + return true +} + +func diagnosticsComponentValid(value string) bool { + if len(value) != 52 || value != strings.ToLower(value) { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && (character < '2' || character > '7') { + return false + } + } + return true +} diff --git a/go/bridge/folderstatus_test.go b/go/bridge/folderstatus_test.go index b40528e..e03cbc7 100644 --- a/go/bridge/folderstatus_test.go +++ b/go/bridge/folderstatus_test.go @@ -2,10 +2,65 @@ package bridge import ( "encoding/json" + "os" "path/filepath" + "strings" "testing" ) +func TestDiagnosticsUploadPathAvailableIsExactAndReadOnly(t *testing.T) { + configDir := testConfigDir(t) + if errMsg := StartSyncthing(configDir); errMsg != "" { + t.Fatalf("StartSyncthing() failed: %s", errMsg) + } + defer StopSyncthing() + + folderPath := filepath.Join(configDir, "diagnostics-folder") + if errMsg := AddFolder("diagnostics-folder", "Diagnostics Folder", folderPath); errMsg != "" { + t.Fatalf("AddFolder failed: %s", errMsg) + } + installation := strings.Repeat("a", 52) + operation := strings.Repeat("b", 52) + operationsPath := filepath.Join( + folderPath, + diagnosticsNamespaceRoot, + "installations", + installation, + "operations", + ) + if err := os.MkdirAll(operationsPath, 0o700); err != nil { + t.Fatal(err) + } + + if !DiagnosticsUploadPathAvailable("diagnostics-folder", installation, operation) { + t.Fatal("exact empty operation slot should be available") + } + if DiagnosticsUploadPathAvailable("diagnostics-folder", "../escape", operation) { + t.Fatal("non-canonical component was accepted") + } + + if errMsg := SetFolderIgnores("diagnostics-folder", `["VaultSync Diagnostics"]`); errMsg != "" { + t.Fatalf("SetFolderIgnores failed: %s", errMsg) + } + if DiagnosticsUploadPathAvailable("diagnostics-folder", installation, operation) { + t.Fatal("ignored diagnostics root was accepted") + } + if errMsg := SetFolderIgnores("diagnostics-folder", `[]`); errMsg != "" { + t.Fatalf("clear ignores failed: %s", errMsg) + } + + requestPath := filepath.Join(operationsPath, operation+".request.cbor") + if err := os.WriteFile(requestPath, []byte("collision"), 0o600); err != nil { + t.Fatal(err) + } + if DiagnosticsUploadPathAvailable("diagnostics-folder", installation, operation) { + t.Fatal("existing operation artifact was accepted") + } + if !DiagnosticsUploadPathAllowed("diagnostics-folder", installation, operation) { + t.Fatal("existing signed-artifact slot should remain ignore/access allowed") + } +} + func TestGetFolderStatusJSONMissingFolderHasErrorDetails(t *testing.T) { configDir := testConfigDir(t) if errMsg := StartSyncthing(configDir); errMsg != "" { diff --git a/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift index 87136f0..0bfa239 100644 --- a/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift +++ b/ios/VaultSync/Services/DiagnosticsCapabilityNamespaceProtocol.swift @@ -623,7 +623,7 @@ enum DiagnosticsNamespaceProtocol { } } - private static func installationBinding( + static func installationBinding( initialAppKeyID: Data, homeserverBinding: Data, folderBinding: Data @@ -634,7 +634,7 @@ enum DiagnosticsNamespaceProtocol { return DiagnosticsCrypto.sha256(domain: installationBindingDomain, body: body) } - private static func base32LowerNoPadding(_ data: Data) -> String { + static func base32LowerNoPadding(_ data: Data) -> String { let alphabet = Array("abcdefghijklmnopqrstuvwxyz234567".utf8) var result: [UInt8] = [] var buffer: UInt64 = 0 @@ -654,6 +654,25 @@ enum DiagnosticsNamespaceProtocol { return String(decoding: result, as: UTF8.self) } + static func operationRequestComponents( + installationBinding: Data, + operationID: Data + ) throws -> [String] { + guard installationBinding.count == 32, + operationID.count == 32, + installationBinding.contains(where: { $0 != 0 }), + operationID.contains(where: { $0 != 0 }) else { + throw DiagnosticsProtocolError.invalidMessage + } + return [ + rootName, + "installations", + base32LowerNoPadding(installationBinding), + "operations", + base32LowerNoPadding(operationID) + ".request.cbor", + ] + } + 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 8b3b238..592d2dc 100644 --- a/ios/VaultSync/Services/DiagnosticsPairingController.swift +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -24,16 +24,72 @@ final class DiagnosticsPairingController { case recoveryRequired } + enum UploadPhase: String, Equatable, Sendable { + case preflighting + case checking + case uploadObserved + case cancelled + case timedOut + case interrupted + case conflict + case rateLimited + case unsupported + case unavailable + } + + struct UploadEvidence: Equatable, Sendable { + var uploadObserved = false + let downloadObserved = false + let roundtripConfirmed = false + } + + struct UploadStatus: Equatable, Sendable { + var phase: UploadPhase + var evidence = UploadEvidence() + var completedPolls = 0 + } + + private struct UploadTuple: Hashable, Sendable { + let recordID: String + let homeserverDeviceID: String + let folderID: String + let homeserverBinding: Data + let folderBinding: Data + let appKeyID: Data + let helperKeyID: Data + let appEpoch: UInt64 + let helperEpoch: UInt64 + let namespaceID: Data? + let namespaceAuthorizationDigest: Data? + let namespaceAuthorizationEpoch: UInt64 + } + typealias TransportFactory = @Sendable (String, UInt16, Data) throws -> any DiagnosticsTransporting + typealias UploadPreflightProvider = @MainActor ( + _ installationComponent: String, + _ operationComponent: String, + _ requireEmptySlot: Bool + ) -> DiagnosticsUploadPreflight + typealias UploadRescan = @MainActor () -> Bool private let credentialStore: DiagnosticsCredentialStore private let transportFactory: TransportFactory private let now: @Sendable () -> Date private let continuousNow: @Sendable () -> TimeInterval + private let uploadRandomBytes: @Sendable (Int) throws -> Data + private let uploadFileWriter: @Sendable (String, [String], Data) throws -> Void + private let uploadSleep: @Sendable (UInt64) async throws -> Void private var capabilityValidUntil: [String: TimeInterval] = [:] + private var uploadTasks: [String: Task] = [:] + private var uploadRunIDs: [String: UUID] = [:] + private var activeUploadTuples: [UploadTuple: UUID] = [:] + private var uploadStartsByRecord: [String: [TimeInterval]] = [:] + private var uploadRequestsByRecord: [String: [TimeInterval]] = [:] + private var uploadRequests: [TimeInterval] = [] private(set) var records: [DiagnosticsPairingRecord] = [] private(set) var capabilityStates: [String: CapabilityState] = [:] + private(set) var uploadStatuses: [String: UploadStatus] = [:] private(set) var notice: Notice = .none private(set) var lastError: DiagnosticsProtocolError? private(set) var isBusy = false @@ -46,15 +102,30 @@ final class DiagnosticsPairingController { try DiagnosticsPinnedTransport(host: host, port: port, pin: pin) }, now: @escaping @Sendable () -> Date = Date.init, - continuousNow: @escaping @Sendable () -> TimeInterval = DiagnosticsContinuousClock.seconds + continuousNow: @escaping @Sendable () -> TimeInterval = DiagnosticsContinuousClock.seconds, + uploadRandomBytes: @escaping @Sendable (Int) throws -> Data = DiagnosticsCrypto.randomBytes, + uploadFileWriter: @escaping @Sendable (String, [String], Data) throws -> Void = { + try DiagnosticsUploadFileStore.createImmutable(folderPath: $0, components: $1, data: $2) + }, + uploadSleep: @escaping @Sendable (UInt64) async throws -> Void = { + try await ContinuousClock().sleep(for: .seconds(Int64($0))) + } ) { self.credentialStore = credentialStore self.transportFactory = transportFactory self.now = now self.continuousNow = continuousNow + self.uploadRandomBytes = uploadRandomBytes + self.uploadFileWriter = uploadFileWriter + self.uploadSleep = uploadSleep } func refresh() { + uploadTasks.values.forEach { $0.cancel() } + uploadTasks = [:] + uploadRunIDs = [:] + activeUploadTuples = [:] + uploadStatuses = [:] do { let inspection = try credentialStore.inspection() hasInstallationMarker = inspection.hasMarker @@ -313,6 +384,359 @@ final class DiagnosticsPairingController { } } + func beginForegroundUpload( + recordID: String, + preflight: @escaping UploadPreflightProvider, + rescan: @escaping UploadRescan + ) { + guard uploadTasks[recordID] == nil else { return } + lastError = nil + uploadStatuses[recordID] = UploadStatus(phase: .preflighting) + let runID = UUID() + uploadRunIDs[recordID] = runID + let task = Task { [weak self] in + guard let self else { return } + await self.runForegroundUpload( + recordID: recordID, + runID: runID, + preflight: preflight, + rescan: rescan + ) + } + uploadTasks[recordID] = task + } + + func cancelForegroundUpload(recordID: String) { + guard let task = uploadTasks[recordID] else { return } + task.cancel() + if let status = uploadStatuses[recordID], + [.preflighting, .checking].contains(status.phase) { + uploadStatuses[recordID] = UploadStatus( + phase: .cancelled, + evidence: status.evidence, + completedPolls: status.completedPolls + ) + } + } + + func cancelAllForegroundUploads() { + for recordID in uploadTasks.keys { + cancelForegroundUpload(recordID: recordID) + } + } + + private func runForegroundUpload( + recordID: String, + runID: UUID, + preflight: @escaping UploadPreflightProvider, + rescan: @escaping UploadRescan + ) async { + var tupleKey: UploadTuple? + var artifactCreated = false + defer { + if let tupleKey, activeUploadTuples[tupleKey] == runID { + activeUploadTuples.removeValue(forKey: tupleKey) + } + if uploadRunIDs[recordID] == runID { + uploadTasks.removeValue(forKey: recordID) + uploadRunIDs.removeValue(forKey: recordID) + } + } + do { + try requireCurrentUploadRun(recordID: recordID, runID: runID) + let record = try requiredRecord(recordID) + guard record.state == .namespaceActive else { + throw DiagnosticsProtocolError.unsupported + } + try requireCurrentCapability(recordID) + guard let initialAppKeyID = record.namespaceInitialAppKeyID else { + throw DiagnosticsProtocolError.unavailable + } + let expectedInstallation = DiagnosticsNamespaceProtocol.installationBinding( + initialAppKeyID: initialAppKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + let installationComponent = DiagnosticsNamespaceProtocol.base32LowerNoPadding( + expectedInstallation + ) + let generalPreflight = preflight( + installationComponent, + String(repeating: "a", count: 52), + false + ) + try generalPreflight.validate(record: record, requireEmptySlot: false) + let installation = try DiagnosticsUploadProtocol.verifyActiveNamespace( + record: record, + folderPath: generalPreflight.folderPath + ) + guard installation == expectedInstallation else { + throw DiagnosticsProtocolError.conflict + } + + let operationID = try uploadRandomBytes(32) + let requestNonce = try uploadRandomBytes(32) + let queryNonce = try uploadRandomBytes(32) + let payload = try uploadRandomBytes(DiagnosticsUploadProtocol.payloadByteCount) + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: record.appSeed) + let operation = try DiagnosticsUploadProtocol.makeOperation( + record: record, + appKey: appKey, + operationID: operationID, + requestNonce: requestNonce, + queryNonce: queryNonce, + payload: payload, + now: now() + ) + guard operation.installationBinding == installation else { + throw DiagnosticsProtocolError.conflict + } + let operationComponent = DiagnosticsNamespaceProtocol.base32LowerNoPadding(operationID) + let exactPreflight = preflight(installationComponent, operationComponent, true) + try exactPreflight.validate(record: record, requireEmptySlot: true) + guard exactPreflight.sameRuntimeBoundary(as: generalPreflight) else { + throw DiagnosticsProtocolError.unavailable + } + + let key = uploadTupleKey(record) + try beginUploadLease(tupleKey: key, recordID: recordID, runID: runID) + tupleKey = key + uploadStatuses[recordID] = UploadStatus(phase: .checking) + let start = continuousNow() + guard start.isFinite, start >= 0 else { + throw DiagnosticsProtocolError.unavailable + } + let deadline = start + TimeInterval(DiagnosticsUploadProtocol.maximumLifetime) + guard deadline.isFinite else { throw DiagnosticsProtocolError.unavailable } + + try uploadFileWriter( + exactPreflight.folderPath, + operation.requestComponents, + operation.request.canonical + ) + artifactCreated = true + guard rescan() else { throw DiagnosticsProtocolError.unavailable } + + 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 + } + _ = try DiagnosticsUploadProtocol.verifyActiveNamespace( + record: currentRecord, + folderPath: currentPreflight.folderPath + ) + let persistedRequest = try DiagnosticsNamespaceFileReader.read( + folderPath: currentPreflight.folderPath, + components: operation.requestComponents + ) + guard persistedRequest == operation.request.canonical else { + throw DiagnosticsProtocolError.conflict + } + try consumeUploadRequest(recordID: recordID) + let transport = try makeTransport(currentRecord) + let response = try await transport.post( + path: DiagnosticsUploadProtocol.path, + body: operation.query.canonical, + responseBody: true + ) + try requireCurrentUploadRun(recordID: recordID, runID: runID) + var status = uploadStatuses[recordID] ?? UploadStatus(phase: .checking) + status.completedPolls = index + 1 + uploadStatuses[recordID] = status + guard let response 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 finalRequest = try DiagnosticsNamespaceFileReader.read( + folderPath: finalPreflight.folderPath, + components: operation.requestComponents + ) + guard finalRequest == operation.request.canonical else { + throw DiagnosticsProtocolError.conflict + } + _ = try DiagnosticsUploadProtocol.validateUploadAttestation( + response, + operation: operation, + record: finalRecord, + now: now() + ) + uploadStatuses[recordID] = UploadStatus( + phase: .uploadObserved, + evidence: UploadEvidence(uploadObserved: true), + completedPolls: index + 1 + ) + return + } + throw DiagnosticsProtocolError.expired + } catch is CancellationError { + if uploadRunIDs[recordID] == runID, + let status = uploadStatuses[recordID], + [.preflighting, .checking].contains(status.phase) { + uploadStatuses[recordID] = UploadStatus( + phase: .cancelled, + evidence: status.evidence, + completedPolls: status.completedPolls + ) + } + } catch let error as DiagnosticsProtocolError { + guard uploadRunIDs[recordID] == runID else { return } + lastError = error + finishUploadFailure(recordID: recordID, error: error, artifactCreated: artifactCreated) + } catch { + guard uploadRunIDs[recordID] == runID else { return } + lastError = .unavailable + finishUploadFailure(recordID: recordID, error: .unavailable, artifactCreated: artifactCreated) + } + } + + private func requireCurrentUploadRun(recordID: String, runID: UUID) throws { + try Task.checkCancellation() + guard uploadRunIDs[recordID] == runID else { + throw CancellationError() + } + } + + private func requireCurrentCapability(_ recordID: String) throws { + let current = continuousNow() + guard capabilityStates[recordID] == .available, + let expiry = capabilityValidUntil[recordID], + current.isFinite, + current >= 0, + current < expiry else { + invalidateCapability(recordID) + throw DiagnosticsProtocolError.unavailable + } + } + + private func beginUploadLease( + tupleKey: UploadTuple, + recordID: String, + runID: UUID + ) throws { + let current = continuousNow() + guard current.isFinite, current >= 0, + activeUploadTuples[tupleKey] == nil, + activeUploadTuples.count < 2 else { + throw DiagnosticsProtocolError.rateLimited + } + let hour = pruneUploadWindow(uploadStartsByRecord[recordID] ?? [], now: current, duration: 3_600) + let day = pruneUploadWindow(uploadStartsByRecord[recordID] ?? [], now: current, duration: 86_400) + guard hour.count < 3, day.count < 12 else { + uploadStartsByRecord[recordID] = day + throw DiagnosticsProtocolError.rateLimited + } + uploadStartsByRecord[recordID] = day + [current] + activeUploadTuples[tupleKey] = runID + } + + private func consumeUploadRequest(recordID: String) throws { + let current = continuousNow() + guard current.isFinite, current >= 0 else { + throw DiagnosticsProtocolError.unavailable + } + var byRecord = pruneUploadWindow( + uploadRequestsByRecord[recordID] ?? [], + now: current, + duration: 60 + ) + uploadRequests = pruneUploadWindow(uploadRequests, now: current, duration: 60) + guard byRecord.count < 30, uploadRequests.count < 120 else { + throw DiagnosticsProtocolError.rateLimited + } + byRecord.append(current) + uploadRequests.append(current) + uploadRequestsByRecord[recordID] = byRecord + } + + private func pruneUploadWindow( + _ values: [TimeInterval], + now: TimeInterval, + duration: TimeInterval + ) -> [TimeInterval] { + values.filter { $0 > now - duration && $0 <= now } + } + + private func uploadTupleKey(_ record: DiagnosticsPairingRecord) -> UploadTuple { + UploadTuple( + recordID: record.id, + homeserverDeviceID: record.homeserverDeviceID, + folderID: record.folderID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding, + appKeyID: record.appKeyID, + helperKeyID: record.helperKeyID, + appEpoch: record.appEpoch, + helperEpoch: record.helperEpoch, + namespaceID: record.namespaceID, + namespaceAuthorizationDigest: record.namespaceAuthorizationDigest, + namespaceAuthorizationEpoch: record.namespaceAuthorizationEpoch + ) + } + + private func uploadBindingUnchanged( + _ initial: DiagnosticsPairingRecord, + _ current: DiagnosticsPairingRecord + ) -> Bool { + current.state == .namespaceActive && current == initial + } + + private func finishUploadFailure( + recordID: String, + error: DiagnosticsProtocolError, + artifactCreated: Bool + ) { + let existing = uploadStatuses[recordID] ?? UploadStatus(phase: .unavailable) + let phase: UploadPhase + switch error { + case .expired: + phase = .timedOut + case .rateLimited: + phase = .rateLimited + case .unsupported: + phase = .unsupported + case .conflict, .invalidMessage: + phase = .conflict + case .unavailable, .protectedDataUnavailable, .recoveryRequired: + phase = artifactCreated ? .interrupted : .unavailable + } + uploadStatuses[recordID] = UploadStatus( + phase: phase, + evidence: existing.evidence, + completedPolls: existing.completedPolls + ) + } + func requestNamespaceEnablement(recordID: String) async { await perform { var record = try requiredActiveRecord(recordID) diff --git a/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift index 02d4943..75d8e5b 100644 --- a/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift +++ b/ios/VaultSync/Services/DiagnosticsPinnedTransport.swift @@ -43,6 +43,7 @@ final class DiagnosticsPinnedTransport: DiagnosticsTransporting, @unchecked Send DiagnosticsCapabilityProtocol.path, DiagnosticsNamespaceProtocol.enablementPath, DiagnosticsNamespaceProtocol.authorizationPath, + DiagnosticsUploadProtocol.path, ] guard allowedPaths.contains(path), !body.isEmpty, body.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes else { @@ -107,7 +108,7 @@ final class DiagnosticsPinnedTransport: DiagnosticsTransporting, @unchecked Send } return data case 202: - guard !responseBody, data.isEmpty, Self.contentLength(http) == 0, + guard data.isEmpty, Self.contentLength(http) == 0, http.value(forHTTPHeaderField: "Content-Type") == nil else { throw DiagnosticsProtocolError.invalidMessage } diff --git a/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift b/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift new file mode 100644 index 0000000..8df6932 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift @@ -0,0 +1,100 @@ +import Darwin +import Foundation + +enum DiagnosticsUploadFileStore { + static func createImmutable( + folderPath: String, + components: [String], + data: Data + ) throws { + guard !folderPath.isEmpty, + components.count == 5, + !data.isEmpty, + data.count <= DiagnosticsDeterministicCBOR.maximumMessageBytes, + components.allSatisfy({ + !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") + }) else { + throw DiagnosticsProtocolError.unsupported + } + let root = open(folderPath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard root >= 0 else { throw mappedError(errno) } + var directories = [root] + defer { directories.reversed().forEach { close($0) } } + + var parent = root + for component in components.dropLast() { + let descriptor = component.withCString { + openat(parent, $0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + guard descriptor >= 0 else { throw mappedError(errno) } + var status = stat() + guard fstat(descriptor, &status) == 0, + (status.st_mode & S_IFMT) == S_IFDIR else { + close(descriptor) + throw DiagnosticsProtocolError.conflict + } + directories.append(descriptor) + parent = descriptor + } + + let filename = components.last! + let file = filename.withCString { + openat( + parent, + $0, + O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(S_IRUSR | S_IWUSR) + ) + } + guard file >= 0 else { throw mappedError(errno) } + var committed = false + defer { + close(file) + if !committed { + _ = filename.withCString { unlinkat(parent, $0, 0) } + _ = fsync(parent) + } + } + + try data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress else { + throw DiagnosticsProtocolError.invalidMessage + } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write(file, base.advanced(by: offset), buffer.count - offset) + if written < 0, errno == EINTR { continue } + guard written > 0 else { throw mappedError(errno) } + offset += written + } + } + guard fsync(file) == 0 else { throw mappedError(errno) } + + var status = stat() + guard fstat(file, &status) == 0, + (status.st_mode & S_IFMT) == S_IFREG, + status.st_nlink == 1, + status.st_size == data.count else { + throw DiagnosticsProtocolError.conflict + } + var verified = Data(count: data.count) + let readCount = verified.withUnsafeMutableBytes { buffer in + pread(file, buffer.baseAddress, buffer.count, 0) + } + guard readCount == data.count, verified == data, fsync(parent) == 0 else { + throw DiagnosticsProtocolError.conflict + } + committed = true + } + + private static func mappedError(_ code: Int32) -> DiagnosticsProtocolError { + switch code { + case EEXIST, ELOOP: + return .conflict + case ENOENT, ENOTDIR, EACCES, EPERM: + return .unavailable + default: + return .unsupported + } + } +} diff --git a/ios/VaultSync/Services/DiagnosticsUploadPreflight.swift b/ios/VaultSync/Services/DiagnosticsUploadPreflight.swift new file mode 100644 index 0000000..35fb4e9 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsUploadPreflight.swift @@ -0,0 +1,117 @@ +import Foundation + +struct DiagnosticsUploadPreflight: Equatable, Sendable { + let folderID: String + let folderPath: String + let peerID: String + let engineGeneration: Int64 + let engineRunning: Bool + let pathsSettled: Bool + let folderMode: String + let folderPaused: Bool + let folderHealthy: Bool + let designatedPeerIDs: [String] + let peerConnected: Bool + let peerPaused: Bool + let pathOverlap: Bool + let namespacePathAllowed: Bool + let operationSlotEmpty: Bool + + func validate( + record: DiagnosticsPairingRecord, + requireEmptySlot: Bool + ) throws { + guard engineRunning, + engineGeneration > 0, + peerConnected else { + throw DiagnosticsProtocolError.unavailable + } + guard pathsSettled, + folderID == record.folderID, + peerID == record.homeserverDeviceID, + !folderPath.isEmpty, + folderPath.hasPrefix("/"), + folderMode == "sendreceive", + !folderPaused, + folderHealthy, + designatedPeerIDs == [peerID], + !peerPaused, + !pathOverlap, + namespacePathAllowed, + !requireEmptySlot || operationSlotEmpty else { + throw DiagnosticsProtocolError.unsupported + } + } + + func sameRuntimeBoundary(as initial: DiagnosticsUploadPreflight) -> Bool { + var current = self + var expected = initial + current = current.withoutSlotVerdict() + expected = expected.withoutSlotVerdict() + return current == expected + } + + private func withoutSlotVerdict() -> DiagnosticsUploadPreflight { + DiagnosticsUploadPreflight( + folderID: folderID, + folderPath: folderPath, + peerID: peerID, + engineGeneration: engineGeneration, + engineRunning: engineRunning, + pathsSettled: pathsSettled, + folderMode: folderMode, + folderPaused: folderPaused, + folderHealthy: folderHealthy, + designatedPeerIDs: designatedPeerIDs, + peerConnected: peerConnected, + peerPaused: peerPaused, + pathOverlap: pathOverlap, + namespacePathAllowed: namespacePathAllowed, + operationSlotEmpty: false + ) + } +} + +extension SyncthingManager { + func diagnosticsUploadPreflight( + folderID: String, + peerID: String, + installationComponent: String, + operationComponent: String, + requireEmptySlot: Bool + ) -> DiagnosticsUploadPreflight { + let folder = folders.first { $0.id == folderID } + let peer = devices.first { $0.deviceID == peerID } + let overlap = PathCollisionGuard.overlappingFolderIDs( + folders.map { (id: $0.id, path: $0.path) }, + canonicalize: FolderPathReconciler.canonical + ).contains(folderID) + let pathAllowed = SyncBridgeService.diagnosticsUploadPathAllowed( + folderID: folderID, + installationComponent: installationComponent, + operationComponent: operationComponent + ) + let slotEmpty = requireEmptySlot && SyncBridgeService.diagnosticsUploadPathAvailable( + folderID: folderID, + installationComponent: installationComponent, + operationComponent: operationComponent + ) + return DiagnosticsUploadPreflight( + folderID: folderID, + folderPath: folder?.path ?? "", + peerID: peerID, + engineGeneration: SyncBridgeService.eventStreamGeneration(), + engineRunning: isRunning, + pathsSettled: pathSettlement.settled, + folderMode: folder?.type ?? "", + folderPaused: folder?.paused ?? true, + folderHealthy: folderStatuses[folderID].map { $0.state != "error" } ?? false, + designatedPeerIDs: folder?.deviceIDs.sorted() ?? [], + peerConnected: peer?.connected ?? false, + peerPaused: peer?.paused ?? true, + pathOverlap: overlap, + namespacePathAllowed: pathAllowed, + operationSlotEmpty: slotEmpty + ) + } +} diff --git a/ios/VaultSync/Services/DiagnosticsUploadProtocol.swift b/ios/VaultSync/Services/DiagnosticsUploadProtocol.swift new file mode 100644 index 0000000..7202ae9 --- /dev/null +++ b/ios/VaultSync/Services/DiagnosticsUploadProtocol.swift @@ -0,0 +1,378 @@ +import CryptoKit +import Foundation + +enum DiagnosticsUploadProtocol { + static let path = "/api/v1/diagnostics/attestation" + static let capability = "eu.vaultsync.diagnostics.correlated-roundtrip/1" + static let maximumLifetime: UInt64 = 600 + static let maximumClockSkew: UInt64 = 120 + static let payloadByteCount = 256 + static let pollDelays: [UInt64] = [2, 4, 8, 16, 30, 60, 120, 120] + + enum MessageType: UInt64, Sendable { + case operationRequest = 3 + case attestationQuery = 4 + case uploadAttestation = 5 + } + + struct Message: Equatable, Sendable { + let type: MessageType + let canonical: Data + let value: DiagnosticsCBORValue + let body: Data + let digest: Data + } + + struct Operation: Equatable, Sendable { + let request: Message + let query: Message + let operationID: Data + let installationBinding: Data + let requestComponents: [String] + } + + private static let domains: [MessageType: String] = [ + .operationRequest: "eu.vaultsync.roundtrip/v1/operation-request\0", + .attestationQuery: "eu.vaultsync.roundtrip/v1/attestation-query\0", + .uploadAttestation: "eu.vaultsync.roundtrip/v1/upload-attestation\0", + ] + + private static let expectedLabels: [MessageType: [UInt64]] = [ + .operationRequest: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 255], + .attestationQuery: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 17, 30, 255], + .uploadAttestation: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 30, 31, 255], + ] + + static func makeOperation( + record: DiagnosticsPairingRecord, + appKey: Curve25519.Signing.PrivateKey, + operationID: Data, + requestNonce: Data, + queryNonce: Data, + payload: Data, + now: Date + ) throws -> Operation { + guard record.state == .namespaceActive, + record.namespaceAuthorizationEpoch > 0, + let initialAppKeyID = record.namespaceInitialAppKeyID, + initialAppKeyID.count == 32, + appKey.publicKey.rawRepresentation == record.appPublicKey, + operationID.count == 32, operationID.contains(where: { $0 != 0 }), + requestNonce.count == 32, requestNonce.contains(where: { $0 != 0 }), + queryNonce.count == 32, queryNonce.contains(where: { $0 != 0 }), + payload.count == payloadByteCount else { + throw DiagnosticsProtocolError.invalidMessage + } + let issuedAt = try unixSeconds(now) + let expiresAt = try DiagnosticsPairingProtocol.checkedAdding(issuedAt, maximumLifetime) + + func common(_ type: MessageType) -> [DiagnosticsCBORField] { + [ + DiagnosticsCBORField(label: 1, value: .text(capability)), + DiagnosticsCBORField(label: 2, value: .unsigned(1)), + DiagnosticsCBORField(label: 3, value: .unsigned(1)), + DiagnosticsCBORField(label: 4, value: .unsigned(type.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(operationID)), + DiagnosticsCBORField(label: 12, value: .unsigned(issuedAt)), + DiagnosticsCBORField(label: 13, value: .unsigned(expiresAt)), + ] + } + + let payloadDigest = DiagnosticsCrypto.sha256(payload) + let request = try sign( + .map(common(.operationRequest) + [ + DiagnosticsCBORField(label: 14, value: .bytes(requestNonce)), + DiagnosticsCBORField(label: 15, value: .bytes(payload)), + DiagnosticsCBORField(label: 16, value: .bytes(payloadDigest)), + ]), + as: .operationRequest, + with: appKey, + record: record + ) + let query = try sign( + .map(common(.attestationQuery) + [ + DiagnosticsCBORField(label: 17, value: .bytes(request.digest)), + DiagnosticsCBORField(label: 30, value: .bytes(queryNonce)), + ]), + as: .attestationQuery, + with: appKey, + record: record + ) + try validateRequestAndQuery(request, query) + + let installation = DiagnosticsNamespaceProtocol.installationBinding( + initialAppKeyID: initialAppKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + return Operation( + request: request, + query: query, + operationID: operationID, + installationBinding: installation, + requestComponents: try DiagnosticsNamespaceProtocol.operationRequestComponents( + installationBinding: installation, + operationID: operationID + ) + ) + } + + static func validateUploadAttestation( + _ data: Data, + operation: Operation, + record: DiagnosticsPairingRecord, + now: Date + ) throws -> Message { + try validateClock(operation.request, now: now) + try validateClock(operation.query, now: now) + let attestation = try decode(data, record: record) + try validateClock(attestation, now: now) + try validateRequestAndQuery(operation.request, operation.query) + guard attestation.type == .uploadAttestation, + commonFieldsEqual(operation.request, attestation), + attestation.value.bytes(for: 16, count: 32) == operation.request.value.bytes(for: 16, count: 32), + attestation.value.bytes(for: 17, count: 32) == operation.request.digest, + attestation.value.bytes(for: 30, count: 32) == operation.query.value.bytes(for: 30, count: 32), + attestation.value.bytes(for: 31, count: 32) == operation.query.digest, + let attestationExpiry = attestation.value.unsigned(for: 13), + let requestExpiry = operation.request.value.unsigned(for: 13), + let queryExpiry = operation.query.value.unsigned(for: 13), + attestationExpiry <= requestExpiry, + attestationExpiry <= queryExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + return attestation + } + + 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 == .uploadAttestation ? 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) + ) + } + + static func verifyActiveNamespace( + record: DiagnosticsPairingRecord, + folderPath: String + ) throws -> Data { + guard record.state == .namespaceActive, + record.namespaceAuthorizationEpoch > 0, + let initialAppKeyID = record.namespaceInitialAppKeyID, + let namespaceID = record.namespaceID, + let rootDigest = record.namespaceRootDigest, + let manifestDigest = record.namespaceManifestDigest, + let authorizationDigest = record.namespaceAuthorizationDigest, + let enablement = record.namespaceEnablement, + let completedSnapshot = record.lastIncoming else { + throw DiagnosticsProtocolError.unavailable + } + let installation = DiagnosticsNamespaceProtocol.installationBinding( + initialAppKeyID: initialAppKeyID, + homeserverBinding: record.homeserverBinding, + folderBinding: record.folderBinding + ) + let rootData = try DiagnosticsNamespaceFileReader.read( + folderPath: folderPath, + components: [DiagnosticsNamespaceProtocol.rootName, DiagnosticsNamespaceProtocol.rootManifestName] + ) + guard DiagnosticsNamespaceProtocol.recordDigest(rootData) == rootDigest, + let rootValue = try? DiagnosticsDeterministicCBOR.decode(rootData), + rootValue.bytes(for: 5, count: 32) == record.homeserverBinding, + rootValue.bytes(for: 6, count: 32) == record.folderBinding, + rootValue.bytes(for: 7, count: 32) == namespaceID else { + throw DiagnosticsProtocolError.conflict + } + let root: DiagnosticsNamespaceProtocol.RootManifest + if record.namespaceAuthorizationEpoch == 1 { + root = try DiagnosticsNamespaceProtocol.validateRootManifest( + rootData, + enablement: enablement, + record: record + ) + } else { + root = DiagnosticsNamespaceProtocol.RootManifest( + message: rootData, + namespaceID: namespaceID, + rootDigest: rootDigest, + manifestDigest: manifestDigest + ) + } + let candidate = DiagnosticsNamespaceProtocol.AuthorizationCandidate( + message: record.lastOutgoing, + installationBinding: installation + ) + let relative: String + if record.namespaceAuthorizationEpoch == 1 { + relative = try DiagnosticsNamespaceProtocol.authorizationRelativePath( + installationBinding: installation + ) + } else { + relative = try DiagnosticsNamespaceProtocol.authorizationEpochRelativePath( + installationBinding: installation, + epoch: record.namespaceAuthorizationEpoch + ) + } + let completed = try DiagnosticsNamespaceFileReader.read( + folderPath: folderPath, + components: [DiagnosticsNamespaceProtocol.rootName] + relative.split(separator: "/").map(String.init) + ) + guard completed == completedSnapshot else { + throw DiagnosticsProtocolError.conflict + } + let digest: Data + if record.namespaceAuthorizationEpoch == 1 { + digest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorization( + completed, + candidate: candidate, + record: record, + root: root + ) + } else { + digest = try DiagnosticsNamespaceProtocol.validateCompletedAuthorizationEpoch( + completed, + candidate: candidate, + record: record, + root: root + ) + } + guard digest == authorizationDigest else { + throw DiagnosticsProtocolError.conflict + } + return installation + } + + 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 validateRequestAndQuery(_ request: Message, _ query: Message) throws { + guard request.type == .operationRequest, + query.type == .attestationQuery, + commonFieldsEqual(request, query), + query.value.bytes(for: 17, count: 32) == request.digest, + let requestIssued = request.value.unsigned(for: 12), + let requestExpiry = request.value.unsigned(for: 13), + let queryIssued = query.value.unsigned(for: 12), + let queryExpiry = query.value.unsigned(for: 13), + queryIssued >= requestIssued, + queryExpiry <= requestExpiry else { + throw DiagnosticsProtocolError.invalidMessage + } + } + + private static func validateFields( + _ value: DiagnosticsCBORValue, + type: MessageType, + record: DiagnosticsPairingRecord + ) throws { + guard value.text(for: 1) == 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 <= maximumLifetime else { + throw DiagnosticsProtocolError.invalidMessage + } + switch type { + case .operationRequest: + guard value.bytes(for: 14, count: 32)?.contains(where: { $0 != 0 }) == true, + let payload = value.bytes(for: 15, count: payloadByteCount), + value.bytes(for: 16, count: 32) == DiagnosticsCrypto.sha256(payload) else { + throw DiagnosticsProtocolError.invalidMessage + } + case .attestationQuery: + guard value.bytes(for: 17, count: 32) != nil, + value.bytes(for: 30, count: 32)?.contains(where: { $0 != 0 }) == true else { + throw DiagnosticsProtocolError.invalidMessage + } + case .uploadAttestation: + guard value.bytes(for: 16, count: 32) != nil, + value.bytes(for: 17, count: 32) != nil, + value.bytes(for: 18, count: 32)?.contains(where: { $0 != 0 }) == true, + let observedAt = value.unsigned(for: 19), observedAt > 0, observedAt <= issuedAt, + value.bytes(for: 30, count: 32)?.contains(where: { $0 != 0 }) == true, + value.bytes(for: 31, count: 32) != nil else { + throw DiagnosticsProtocolError.invalidMessage + } + } + } + + private static func validateClock(_ message: Message, now: Date) throws { + guard let issuedAt = message.value.unsigned(for: 12), + let expiresAt = message.value.unsigned(for: 13) else { + throw DiagnosticsProtocolError.invalidMessage + } + let current = try unixSeconds(now) + if issuedAt > current, issuedAt - current > maximumClockSkew { + throw DiagnosticsProtocolError.expired + } + if current > expiresAt, current - expiresAt > maximumClockSkew { + throw DiagnosticsProtocolError.expired + } + } + + private static func commonFieldsEqual(_ lhs: Message, _ rhs: Message) -> Bool { + [1, 2, 3, 5, 6, 7, 8, 9, 10, 11].allSatisfy { + lhs.value.value(for: $0) == rhs.value.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/Services/SyncBridgeService.swift b/ios/VaultSync/Services/SyncBridgeService.swift index d8a2ce8..9b613bf 100644 --- a/ios/VaultSync/Services/SyncBridgeService.swift +++ b/ios/VaultSync/Services/SyncBridgeService.swift @@ -191,6 +191,26 @@ struct SyncBridgeService { BridgeGetFolderIgnores(folderID) } + /// Read-only D024 preflight for one exact authenticated namespace slot. + /// The Go bridge validates fixed lowercase-base32 components, existing + /// descriptor targets, Syncthing's real ignore matcher, and absence of all + /// three operation artifacts. It never creates or changes configuration. + static func diagnosticsUploadPathAvailable( + folderID: String, + installationComponent: String, + operationComponent: String + ) -> Bool { + BridgeDiagnosticsUploadPathAvailable(folderID, installationComponent, operationComponent) + } + + static func diagnosticsUploadPathAllowed( + folderID: String, + installationComponent: String, + operationComponent: String + ) -> Bool { + BridgeDiagnosticsUploadPathAllowed(folderID, installationComponent, operationComponent) + } + /// Set .stignore lines for a folder. ignoresJSON is a JSON array of strings. /// - Returns: nil on success, error message on failure. static func setFolderIgnores(folderID: String, ignoresJSON: String) -> String? { diff --git a/ios/VaultSync/Views/ControlledDiagnosticsView.swift b/ios/VaultSync/Views/ControlledDiagnosticsView.swift index 8d556cf..b6a841c 100644 --- a/ios/VaultSync/Views/ControlledDiagnosticsView.swift +++ b/ios/VaultSync/Views/ControlledDiagnosticsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ControlledDiagnosticsView: View { let syncthingManager: SyncthingManager + @Environment(\.scenePhase) private var scenePhase @State private var controller = DiagnosticsPairingController() @State private var selectedDeviceID = "" @State private var selectedFolderID = "" @@ -12,6 +13,8 @@ struct ControlledDiagnosticsView: View { @State private var consentAction: ConsentAction = .scan @State private var showRecoveryConfirmation = false @State private var missingFolderRecordID: String? + @State private var pendingUploadRecordID: String? + @State private var showUploadConsent = false private enum ConsentAction { case scan @@ -34,6 +37,14 @@ struct ControlledDiagnosticsView: View { controller.refresh() chooseInitialTarget() } + .onDisappear { + controller.cancelAllForegroundUploads() + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { + controller.cancelAllForegroundUploads() + } + } .onChange(of: selectedDeviceID) { _, _ in if !eligibleFolders.contains(where: { $0.id == selectedFolderID }) { selectedFolderID = eligibleFolders.first?.id ?? "" @@ -72,6 +83,16 @@ 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) { + Button(L10n.tr("Cancel"), role: .cancel) { + pendingUploadRecordID = nil + } + Button(L10n.tr("Start Upload 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.")) + } } private var explanationSection: some View { @@ -79,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. A later 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 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.")) .font(.caption) .foregroundStyle(.secondary) } header: { @@ -296,9 +317,40 @@ struct ControlledDiagnosticsView: View { } } case .namespaceActive: - Label(L10n.tr("Namespace authorized — no transfer artifact created"), systemImage: "checkmark.shield") + Label(L10n.tr("Namespace authorized"), systemImage: "checkmark.shield") .font(.caption) .foregroundStyle(Color.statusSuccess) + Text(L10n.fmt( + "Upload target: %@ · designated peer: %@", + folderName(record.folderID), + deviceName(record.homeserverDeviceID) + )) + .font(.caption) + .foregroundStyle(.secondary) + if let status = controller.uploadStatuses[record.id] { + Label(uploadStatusLabel(status), systemImage: uploadStatusSymbol(status.phase)) + .font(.caption) + .foregroundStyle(uploadStatusColor(status.phase)) + Text(L10n.tr("Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field.")) + .font(.caption2) + .foregroundStyle(.secondary) + } + if let status = controller.uploadStatuses[record.id], + [.preflighting, .checking].contains(status.phase) { + Button(L10n.tr("Cancel Upload Check"), role: .cancel) { + controller.cancelForegroundUpload(recordID: record.id) + } + } else if capability == .available { + Button(L10n.tr("Start Foreground Upload Check")) { + pendingUploadRecordID = record.id + showUploadConsent = true + } + .buttonStyle(.borderedProminent) + } else { + Text(L10n.tr("Check authenticated capability immediately before starting an upload check.")) + .font(.caption) + .foregroundStyle(.secondary) + } default: EmptyView() } @@ -463,6 +515,73 @@ struct ControlledDiagnosticsView: View { } } + private func startPendingUpload() { + guard let recordID = pendingUploadRecordID, + let record = controller.records.first(where: { $0.id == recordID }) else { + pendingUploadRecordID = nil + return + } + pendingUploadRecordID = nil + controller.beginForegroundUpload( + recordID: record.id, + preflight: { installationComponent, operationComponent, requireEmptySlot in + syncthingManager.diagnosticsUploadPreflight( + folderID: record.folderID, + peerID: record.homeserverDeviceID, + installationComponent: installationComponent, + operationComponent: operationComponent, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { + syncthingManager.rescanFolder(id: record.folderID) == nil + } + ) + } + + private func uploadStatusLabel(_ status: DiagnosticsPairingController.UploadStatus) -> String { + 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 .cancelled: + return L10n.tr("Upload check cancelled — no late result can upgrade it") + case .timedOut: + return L10n.tr("Upload check timed out — upload unobserved") + case .interrupted: + return L10n.tr("Upload check interrupted — upload unobserved") + case .conflict: + return L10n.tr("Upload check found an immutable conflict — upload unobserved") + case .rateLimited: + return L10n.tr("Upload check rate limited — upload unobserved") + case .unsupported: + return L10n.tr("Upload check unsupported for this exact folder and peer") + case .unavailable: + return L10n.tr("Upload capability unavailable — no upload evidence") + } + } + + private func uploadStatusSymbol(_ phase: DiagnosticsPairingController.UploadPhase) -> String { + switch phase { + case .uploadObserved: return "arrow.up.circle.fill" + case .preflighting, .checking: return "hourglass" + case .cancelled, .timedOut, .interrupted, .unavailable: return "exclamationmark.circle" + case .conflict, .rateLimited, .unsupported: return "xmark.shield" + } + } + + private func uploadStatusColor(_ phase: DiagnosticsPairingController.UploadPhase) -> Color { + switch phase { + case .uploadObserved: return Color.statusSuccess + case .preflighting, .checking: return Color.statusAttention + case .cancelled, .timedOut, .interrupted, .unavailable: return Color.statusAttention + case .conflict, .rateLimited, .unsupported: return Color.statusError + } + } + private func errorLabel(_ error: DiagnosticsProtocolError) -> String { switch error { case .invalidMessage: return L10n.tr("The authenticated protocol response was invalid.") diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 721b900..1307d1d 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -914,3 +914,22 @@ "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."; +"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"; +"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 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"; +"Upload check found an immutable conflict — upload unobserved" = "Upload-Prüfung fand einen unveränderlichen Konflikt — Upload unbeobachtet"; +"Upload check rate limited — upload unobserved" = "Upload-Prüfung ratenbegrenzt — Upload unbeobachtet"; +"Upload check unsupported for this exact folder and peer" = "Upload-Prüfung für genau diesen Ordner und Peer nicht unterstützt"; +"Upload capability unavailable — no upload evidence" = "Upload-Capability nicht verfügbar — keine Upload-Evidence"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 0e74eec..65fffc2 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -914,3 +914,22 @@ "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."; +"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"; +"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 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"; +"Upload check found an immutable conflict — upload unobserved" = "Upload check found an immutable conflict — upload unobserved"; +"Upload check rate limited — upload unobserved" = "Upload check rate limited — upload unobserved"; +"Upload check unsupported for this exact folder and peer" = "Upload check unsupported for this exact folder and peer"; +"Upload capability unavailable — no upload evidence" = "Upload capability unavailable — no upload evidence"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index da13ca9..93892a2 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -914,3 +914,22 @@ "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."; +"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"; +"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 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"; +"Upload check found an immutable conflict — upload unobserved" = "La comprobación de carga encontró un conflicto inmutable — carga sin observar"; +"Upload check rate limited — upload unobserved" = "Comprobación de carga limitada por frecuencia — carga sin observar"; +"Upload check unsupported for this exact folder and peer" = "Comprobación de carga no compatible con esta carpeta y este par exactos"; +"Upload capability unavailable — no upload evidence" = "Capacidad de carga no disponible — sin evidencia de carga"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 1227805..9a7bc0a 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -914,3 +914,22 @@ "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." = "配对和能力检查不会产生上传、下载或往返证据。只有单独且明确启动的上传检查才能将上传标记为已观察;下载和往返始终独立。诊断命名空间对同步的对等设备可见,并可能保留在备份、版本、冲突副本和删除记录中。"; +"Upload target: %@ · designated peer: %@" = "上传目标:%@ · 指定对等设备:%@"; +"Upload, download, and roundtrip are separate evidence fields. Cleanup never upgrades any field." = "上传、下载和往返是相互独立的证据字段。清理绝不会提升任何字段。"; +"Cancel Upload Check" = "取消上传检查"; +"Start Foreground Upload 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 check cancelled — no late result can upgrade it" = "上传检查已取消 — 任何迟到结果都不能提升状态"; +"Upload check timed out — upload unobserved" = "上传检查超时 — 未观察到上传"; +"Upload check interrupted — upload unobserved" = "上传检查中断 — 未观察到上传"; +"Upload check found an immutable conflict — upload unobserved" = "上传检查发现不可变冲突 — 未观察到上传"; +"Upload check rate limited — upload unobserved" = "上传检查受到速率限制 — 未观察到上传"; +"Upload check unsupported for this exact folder and peer" = "此精确文件夹和对等设备不支持上传检查"; +"Upload capability unavailable — no upload evidence" = "上传能力不可用 — 没有上传证据"; diff --git a/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift b/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift index faed5c7..2a6e4bd 100644 --- a/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift +++ b/ios/VaultSyncTests/DiagnosticsAppRuntimeM3Tests.swift @@ -1189,7 +1189,7 @@ struct DiagnosticsAppRuntimeM3Tests { private final class DiagnosticsAppRuntimeFixtureToken {} -private final class LockedDiagnosticsClock: @unchecked Sendable { +final class LockedDiagnosticsClock: @unchecked Sendable { private let lock = NSLock() private var date: Date private var continuous: TimeInterval @@ -1231,7 +1231,7 @@ private final class LockedDiagnosticsClock: @unchecked Sendable { } } -private func loadDiagnosticsHexFixture( +func loadDiagnosticsHexFixture( named name: String, filePath: StaticString = #filePath ) throws -> [String: String] { @@ -1244,7 +1244,7 @@ private func loadDiagnosticsHexFixture( return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: bundled ?? fallback)) } -private func makeRuntimeRecord( +func makeRuntimeRecord( appSeed: Data, helperPublic: Data, homeserver: Data, @@ -1325,7 +1325,7 @@ private func makeCapabilityResponse( return try DiagnosticsDeterministicCBOR.encode(.map(fields)) } -private func makeCapabilityResponseForQuery( +func makeCapabilityResponseForQuery( _ queryData: Data, helperKey: Curve25519.Signing.PrivateKey ) throws -> Data { @@ -1777,7 +1777,7 @@ private func pairingDomainForTest(_ type: DiagnosticsPairingProtocol.MessageType return "eu.vaultsync.helper-pairing/v1/\(suffix)\0" } -private func makeNamespaceRoot( +func makeNamespaceRoot( enablement: Data, record: DiagnosticsPairingRecord, helperKey: Curve25519.Signing.PrivateKey, @@ -1812,7 +1812,7 @@ private func makeNamespaceRoot( ) } -private func countersignInitialAuthorization( +func countersignInitialAuthorization( _ candidate: Data, helperKey: Curve25519.Signing.PrivateKey ) throws -> Data { @@ -1845,7 +1845,7 @@ private func removingSignatures(_ encoded: Data, labels: Set) throws -> return try DiagnosticsDeterministicCBOR.encode(value.removing(labels: labels)) } -private final class InMemoryDiagnosticsKeychain: DiagnosticsKeychainAccess, @unchecked Sendable { +final class InMemoryDiagnosticsKeychain: DiagnosticsKeychainAccess, @unchecked Sendable { private var items: [String: [String: Any]] = [:] func attributes(account: String) -> [String: Any]? { diff --git a/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift new file mode 100644 index 0000000..d65e588 --- /dev/null +++ b/ios/VaultSyncTests/DiagnosticsForegroundUploadRuntimeTests.swift @@ -0,0 +1,822 @@ +import CryptoKit +import Foundation +import Testing +@testable import VaultSync + +@Suite("Foreground diagnostics upload runtime (M5)", .serialized) +@MainActor +struct DiagnosticsForegroundUploadRuntimeTests { + @Test("Explicit run accepts only the exact byte-identical pinned query chain") + func exactForegroundUpload() async throws { + let identifier = UUID().uuidString.lowercased() + let support = FileManager.default.temporaryDirectory + .appendingPathComponent("vaultsync-upload-support-\(identifier)", isDirectory: true) + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("vaultsync-upload-folder-\(identifier)", isDirectory: true) + let keychain = InMemoryDiagnosticsKeychain() + let store = DiagnosticsCredentialStore( + applicationSupportURL: support, + service: "eu.vaultsync.app.diagnostics.upload.tests.\(identifier)", + keychain: keychain + ) + defer { + try? store.resetForExplicitRepair() + try? FileManager.default.removeItem(at: support) + try? FileManager.default.removeItem(at: folder) + } + _ = try store.installationCredential() + + let uploadFixture = try M5UploadFixtureLoader.load() + let appSeed = try Data(m1Hex: uploadFixture.appSeedHex) + let helperKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(m1Hex: uploadFixture.helperSeedHex) + ) + var record = makeRuntimeRecord( + appSeed: appSeed, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: try Data(m1Hex: uploadFixture.homeserverBindingHex), + folder: try Data(m1Hex: uploadFixture.folderBindingHex), + folderID: "foreground-upload", + appEpoch: uploadFixture.appEpoch, + helperEpoch: uploadFixture.helperEpoch + ) + let wall = Date(timeIntervalSince1970: TimeInterval(uploadFixture.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: uploadFixture.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) + let operationDirectory = namespace + .appendingPathComponent("installations", isDirectory: true) + .appendingPathComponent( + DiagnosticsNamespaceProtocol.base32LowerNoPadding(candidate.installationBinding), + isDirectory: true + ) + .appendingPathComponent("operations", isDirectory: true) + try FileManager.default.createDirectory( + at: operationDirectory, + withIntermediateDirectories: true + ) + + let clock = LockedDiagnosticsClock(wall, continuous: 1_000) + let requestBox = LockedUploadRequestBox() + let transport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { requestBox.value() }, + acceptAfter: 2 + ) + let random = LockedUploadRandom(values: [ + try Data(m1Hex: uploadFixture.operationIdHex), + try Data(m1Hex: uploadFixture.requestNonceHex), + try Data(m1Hex: uploadFixture.queryNonceHex), + try Data(m1Hex: uploadFixture.requestPayloadHex), + ]) + let controller = DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() }, + uploadRandomBytes: { try random.next(count: $0) }, + 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: record.id) + #expect(controller.capabilityStates[record.id] == .available) + + controller.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + DiagnosticsUploadPreflight( + folderID: record.folderID, + folderPath: folder.path, + peerID: record.homeserverDeviceID, + engineGeneration: 7, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [record.homeserverDeviceID], + peerConnected: true, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForTerminalUpload(controller: controller, recordID: record.id) + + let status = try #require(controller.uploadStatuses[record.id]) + #expect(status.phase == .uploadObserved) + #expect(status.evidence.uploadObserved) + #expect(!status.evidence.downloadObserved) + #expect(!status.evidence.roundtripConfirmed) + #expect(status.completedPolls == 2) + let queries = await transport.uploadQueries() + #expect(queries.count == 2) + #expect(queries[0] == queries[1]) + #expect(requestBox.value() != nil) + + let lateRequestBox = LockedUploadRequestBox() + let lateTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { lateRequestBox.value() }, + acceptAfter: 1, + holdAcceptedResponse: true + ) + let lateRandom = LockedUploadRandom(values: [ + Data(repeating: 0x41, count: 32), + Data(repeating: 0x42, count: 32), + Data(repeating: 0x43, count: 32), + Data(repeating: 0x44, count: DiagnosticsUploadProtocol.payloadByteCount), + ]) + let lateController = makeUploadController( + store: store, + transport: lateTransport, + clock: clock, + random: lateRandom, + requestBox: lateRequestBox + ) + lateController.refresh() + await lateController.checkCapability(recordID: record.id) + lateController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForHeldResponse(lateTransport) + #expect(await lateTransport.isHoldingAcceptedResponse()) + lateController.cancelForegroundUpload(recordID: record.id) + #expect(lateController.uploadStatuses[record.id]?.phase == .cancelled) + await lateTransport.releaseAcceptedResponse() + await waitForReturnedResponse(lateTransport) + for _ in 0..<100 { await Task.yield() } + #expect(lateController.uploadStatuses[record.id]?.phase == .cancelled) + #expect(lateController.uploadStatuses[record.id]?.evidence.uploadObserved == false) + + let restartRequestBox = LockedUploadRequestBox() + let restartTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { restartRequestBox.value() }, + acceptAfter: 1, + holdAcceptedResponse: true + ) + let restartRandom = LockedUploadRandom(values: [ + Data(repeating: 0x51, count: 32), + Data(repeating: 0x52, count: 32), + Data(repeating: 0x53, count: 32), + Data(repeating: 0x54, count: DiagnosticsUploadProtocol.payloadByteCount), + ]) + let restartController = makeUploadController( + store: store, + transport: restartTransport, + clock: clock, + random: restartRandom, + requestBox: restartRequestBox + ) + restartController.refresh() + await restartController.checkCapability(recordID: record.id) + restartController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForHeldResponse(restartTransport) + restartController.refresh() + #expect(restartController.uploadStatuses[record.id] == nil) + await restartTransport.releaseAcceptedResponse() + await waitForReturnedResponse(restartTransport) + for _ in 0..<100 { await Task.yield() } + #expect(restartController.uploadStatuses[record.id] == nil) + #expect(restartController.lastError == nil) + + let racedRequestBox = LockedUploadRequestBox() + let racedTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { racedRequestBox.value() }, + acceptAfter: 1, + holdAcceptedResponse: true + ) + let racedOperationID = Data(repeating: 0x81, count: 32) + let racedController = makeUploadController( + store: store, + transport: racedTransport, + clock: clock, + random: LockedUploadRandom(values: [ + racedOperationID, + Data(repeating: 0x82, count: 32), + Data(repeating: 0x83, count: 32), + Data(repeating: 0x84, count: DiagnosticsUploadProtocol.payloadByteCount), + ]), + requestBox: racedRequestBox + ) + racedController.refresh() + await racedController.checkCapability(recordID: record.id) + racedController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForHeldResponse(racedTransport) + let racedComponents = try DiagnosticsNamespaceProtocol.operationRequestComponents( + installationBinding: candidate.installationBinding, + operationID: racedOperationID + ) + let racedRequestURL = racedComponents.reduce(folder) { + $0.appendingPathComponent($1) + } + try Data([0xa0]).write(to: racedRequestURL) + await racedTransport.releaseAcceptedResponse() + await waitForReturnedResponse(racedTransport) + await waitForTerminalUpload(controller: racedController, recordID: record.id) + #expect(racedController.uploadStatuses[record.id]?.phase == .conflict) + #expect(racedController.uploadStatuses[record.id]?.evidence.uploadObserved == false) + + let rateRequestBox = LockedUploadRequestBox() + let rateTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { rateRequestBox.value() }, + acceptAfter: 1 + ) + var rateValues: [Data] = [] + for operation in 0..<4 { + let first = UInt8(0x61 + operation * 4) + rateValues.append(Data(repeating: first, count: 32)) + rateValues.append(Data(repeating: first + 1, count: 32)) + rateValues.append(Data(repeating: first + 2, count: 32)) + rateValues.append(Data( + repeating: first + 3, + count: DiagnosticsUploadProtocol.payloadByteCount + )) + } + let rateController = makeUploadController( + store: store, + transport: rateTransport, + clock: clock, + random: LockedUploadRandom(values: rateValues), + requestBox: rateRequestBox + ) + rateController.refresh() + await rateController.checkCapability(recordID: record.id) + for _ in 0..<3 { + rateController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForTerminalUpload(controller: rateController, recordID: record.id) + #expect(rateController.uploadStatuses[record.id]?.phase == .uploadObserved) + } + #expect(rateRequestBox.count() == 3) + rateController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForTerminalUpload(controller: rateController, recordID: record.id) + #expect(rateController.uploadStatuses[record.id]?.phase == .rateLimited) + #expect(rateController.uploadStatuses[record.id]?.evidence.uploadObserved == false) + #expect(rateRequestBox.count() == 3) + #expect(await rateTransport.uploadQueries().count == 3) + + let timeoutRequestBox = LockedUploadRequestBox() + let timeoutTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { timeoutRequestBox.value() }, + acceptAfter: 99 + ) + let timeoutController = makeUploadController( + store: store, + transport: timeoutTransport, + clock: clock, + random: LockedUploadRandom(values: [ + Data(repeating: 0x79, count: 32), + Data(repeating: 0x7a, count: 32), + Data(repeating: 0x7b, count: 32), + Data(repeating: 0x7c, count: DiagnosticsUploadProtocol.payloadByteCount), + ]), + requestBox: timeoutRequestBox + ) + timeoutController.refresh() + await timeoutController.checkCapability(recordID: record.id) + timeoutController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + }, + rescan: { true } + ) + await waitForTerminalUpload(controller: timeoutController, recordID: record.id) + let timeout = try #require(timeoutController.uploadStatuses[record.id]) + #expect(timeout.phase == .timedOut) + #expect(timeout.completedPolls == DiagnosticsUploadProtocol.pollDelays.count) + #expect(!timeout.evidence.uploadObserved) + let timeoutQueries = await timeoutTransport.uploadQueries() + #expect(timeoutQueries.count == DiagnosticsUploadProtocol.pollDelays.count) + #expect(Set(timeoutQueries).count == 1) + + let rejectedRequestBox = LockedUploadRequestBox() + let rejectedTransport = ForegroundUploadTransport( + record: record, + helperKey: helperKey, + clock: clock, + request: { rejectedRequestBox.value() }, + acceptAfter: 1 + ) + let rejectedController = makeUploadController( + store: store, + transport: rejectedTransport, + clock: clock, + random: LockedUploadRandom(values: [ + Data(repeating: 0x7d, count: 32), + Data(repeating: 0x7e, count: 32), + Data(repeating: 0x7f, count: 32), + Data(repeating: 0x80, count: DiagnosticsUploadProtocol.payloadByteCount), + ]), + requestBox: rejectedRequestBox + ) + rejectedController.refresh() + await rejectedController.checkCapability(recordID: record.id) + rejectedController.beginForegroundUpload( + recordID: record.id, + preflight: { _, _, requireEmptySlot in + let valid = self.validPreflight( + record: record, + folderPath: folder.path, + requireEmptySlot: requireEmptySlot + ) + return DiagnosticsUploadPreflight( + folderID: valid.folderID, + folderPath: valid.folderPath, + peerID: valid.peerID, + engineGeneration: valid.engineGeneration, + engineRunning: valid.engineRunning, + pathsSettled: valid.pathsSettled, + folderMode: valid.folderMode, + folderPaused: valid.folderPaused, + folderHealthy: valid.folderHealthy, + designatedPeerIDs: [valid.peerID, "ambiguous-peer"], + peerConnected: valid.peerConnected, + peerPaused: valid.peerPaused, + pathOverlap: valid.pathOverlap, + namespacePathAllowed: valid.namespacePathAllowed, + operationSlotEmpty: valid.operationSlotEmpty + ) + }, + rescan: { true } + ) + await waitForTerminalUpload(controller: rejectedController, recordID: record.id) + #expect(rejectedController.uploadStatuses[record.id]?.phase == .unsupported) + #expect(rejectedRequestBox.count() == 0) + #expect(await rejectedTransport.uploadQueries().isEmpty) + } + + @Test("Preflight rejects ambiguous peers and unavailable connectivity before any artifact") + func strictPreflight() throws { + let fixture = try M5UploadFixtureLoader.load() + let appKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(m1Hex: fixture.appSeedHex) + ) + let helperKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: Data(m1Hex: fixture.helperSeedHex) + ) + var record = makeRuntimeRecord( + appSeed: appKey.rawRepresentation, + helperPublic: helperKey.publicKey.rawRepresentation, + homeserver: try Data(m1Hex: fixture.homeserverBindingHex), + folder: try Data(m1Hex: fixture.folderBindingHex) + ) + record.state = .namespaceActive + let valid = DiagnosticsUploadPreflight( + folderID: record.folderID, + folderPath: "/tmp/exact-folder", + peerID: record.homeserverDeviceID, + engineGeneration: 1, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [record.homeserverDeviceID], + peerConnected: true, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: true + ) + try valid.validate(record: record, requireEmptySlot: true) + + let ambiguous = DiagnosticsUploadPreflight( + folderID: valid.folderID, + folderPath: valid.folderPath, + peerID: valid.peerID, + engineGeneration: valid.engineGeneration, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [valid.peerID, "another-peer"], + peerConnected: true, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: true + ) + #expect(throws: DiagnosticsProtocolError.unsupported) { + try ambiguous.validate(record: record, requireEmptySlot: true) + } + + let disconnected = DiagnosticsUploadPreflight( + folderID: valid.folderID, + folderPath: valid.folderPath, + peerID: valid.peerID, + engineGeneration: valid.engineGeneration, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [valid.peerID], + peerConnected: false, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: true + ) + #expect(throws: DiagnosticsProtocolError.unavailable) { + try disconnected.validate(record: record, requireEmptySlot: true) + } + } + + private func waitForTerminalUpload( + controller: DiagnosticsPairingController, + recordID: String + ) async { + for _ in 0..<1_000 { + if let phase = controller.uploadStatuses[recordID]?.phase, + ![.preflighting, .checking].contains(phase) { + return + } + await Task.yield() + } + Issue.record("foreground upload task did not reach a terminal state") + } + + private func makeUploadController( + store: DiagnosticsCredentialStore, + transport: ForegroundUploadTransport, + clock: LockedDiagnosticsClock, + random: LockedUploadRandom, + requestBox: LockedUploadRequestBox + ) -> DiagnosticsPairingController { + DiagnosticsPairingController( + credentialStore: store, + transportFactory: { _, _, _ in transport }, + now: { clock.value() }, + continuousNow: { clock.continuousValue() }, + uploadRandomBytes: { try random.next(count: $0) }, + 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() + } + ) + } + + private func validPreflight( + record: DiagnosticsPairingRecord, + folderPath: String, + requireEmptySlot: Bool + ) -> DiagnosticsUploadPreflight { + DiagnosticsUploadPreflight( + folderID: record.folderID, + folderPath: folderPath, + peerID: record.homeserverDeviceID, + engineGeneration: 7, + engineRunning: true, + pathsSettled: true, + folderMode: "sendreceive", + folderPaused: false, + folderHealthy: true, + designatedPeerIDs: [record.homeserverDeviceID], + peerConnected: true, + peerPaused: false, + pathOverlap: false, + namespacePathAllowed: true, + operationSlotEmpty: requireEmptySlot + ) + } + + private func waitForHeldResponse(_ transport: ForegroundUploadTransport) async { + for _ in 0..<1_000 { + if await transport.isHoldingAcceptedResponse() { return } + await Task.yield() + } + Issue.record("upload transport did not hold the accepted response") + } + + private func waitForReturnedResponse(_ transport: ForegroundUploadTransport) async { + for _ in 0..<1_000 { + if await transport.hasReturnedAcceptedResponse() { return } + await Task.yield() + } + Issue.record("upload transport did not return the accepted response") + } +} + +private final class LockedUploadRequestBox: @unchecked Sendable { + private let lock = NSLock() + private var request: Data? + private var writes = 0 + + func set(_ value: Data) { + lock.lock() + request = value + writes += 1 + lock.unlock() + } + + func value() -> Data? { + lock.lock() + defer { lock.unlock() } + return request + } + + func count() -> Int { + lock.lock() + defer { lock.unlock() } + return writes + } +} + +private final class LockedUploadRandom: @unchecked Sendable { + private let lock = NSLock() + private var values: [Data] + + init(values: [Data]) { + self.values = values + } + + func next(count: Int) throws -> Data { + lock.lock() + defer { lock.unlock() } + guard !values.isEmpty else { throw DiagnosticsProtocolError.unavailable } + let value = values.removeFirst() + guard value.count == count else { throw DiagnosticsProtocolError.invalidMessage } + return value + } +} + +private 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 var queries: [Data] = [] + private var acceptedResponseContinuation: CheckedContinuation? + private var acceptedResponseReleased = false + private var holdingAcceptedResponse = false + private var returnedAcceptedResponse = false + + init( + record: DiagnosticsPairingRecord, + helperKey: Curve25519.Signing.PrivateKey, + clock: LockedDiagnosticsClock, + request: @escaping @Sendable () -> Data?, + acceptAfter: Int, + holdAcceptedResponse: Bool = false + ) { + self.record = record + self.helperKey = helperKey + self.clock = clock + self.request = request + self.acceptAfter = acceptAfter + self.holdAcceptedResponse = holdAcceptedResponse + } + + func post(path: String, body: Data, responseBody: Bool) async throws -> Data? { + switch path { + case DiagnosticsCapabilityProtocol.path: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + return try makeCapabilityResponseForQuery(body, helperKey: helperKey) + case DiagnosticsUploadProtocol.path: + guard responseBody else { throw DiagnosticsProtocolError.invalidMessage } + queries.append(body) + guard queries.count >= acceptAfter else { return nil } + guard let requestBytes = request() else { throw DiagnosticsProtocolError.unavailable } + if holdAcceptedResponse, !acceptedResponseReleased { + holdingAcceptedResponse = true + await withCheckedContinuation { continuation in + if acceptedResponseReleased { + continuation.resume() + } else { + acceptedResponseContinuation = continuation + } + } + holdingAcceptedResponse = false + } + let requestMessage = try DiagnosticsUploadProtocol.decode(requestBytes, record: record) + let queryMessage = try DiagnosticsUploadProtocol.decode(body, record: record) + let now = UInt64(clock.value().timeIntervalSince1970.rounded(.down)) + let requestExpiry = try #require(requestMessage.value.unsigned(for: 13)) + let queryExpiry = try #require(queryMessage.value.unsigned(for: 13)) + let expiry = min( + min(requestExpiry, queryExpiry), + now + DiagnosticsUploadProtocol.maximumLifetime + ) + 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(5)), + 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(try #require(queryMessage.value.bytes(for: 11, count: 32))) + ), + DiagnosticsCBORField(label: 12, value: .unsigned(now)), + DiagnosticsCBORField(label: 13, value: .unsigned(expiry)), + DiagnosticsCBORField( + label: 16, + value: .bytes(try #require(requestMessage.value.bytes(for: 16, count: 32))) + ), + DiagnosticsCBORField(label: 17, value: .bytes(requestMessage.digest)), + DiagnosticsCBORField( + label: 18, + value: .bytes(DiagnosticsCrypto.sha256(requestMessage.digest)) + ), + DiagnosticsCBORField(label: 19, value: .unsigned(now)), + DiagnosticsCBORField( + label: 30, + value: .bytes(try #require(queryMessage.value.bytes(for: 30, count: 32))) + ), + DiagnosticsCBORField(label: 31, value: .bytes(queryMessage.digest)), + ]) + let unsigned = try DiagnosticsDeterministicCBOR.encode(value) + var input = Data("eu.vaultsync.roundtrip/v1/upload-attestation\0".utf8) + input.append(unsigned) + guard case .map(var fields) = value else { + throw DiagnosticsProtocolError.invalidMessage + } + fields.append(DiagnosticsCBORField( + label: 255, + value: .bytes(try helperKey.signature(for: input)) + )) + let response = try DiagnosticsDeterministicCBOR.encode(.map(fields)) + returnedAcceptedResponse = true + return response + default: + throw DiagnosticsProtocolError.invalidMessage + } + } + + func uploadQueries() -> [Data] { queries } + + func isHoldingAcceptedResponse() -> Bool { holdingAcceptedResponse } + + func releaseAcceptedResponse() { + acceptedResponseReleased = true + let continuation = acceptedResponseContinuation + acceptedResponseContinuation = nil + continuation?.resume() + } + + func hasReturnedAcceptedResponse() -> Bool { returnedAcceptedResponse } +} diff --git a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift index 43a011c..fc1d0b0 100644 --- a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift +++ b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift @@ -1,8 +1,9 @@ import CryptoKit import Foundation import Testing +@testable import VaultSync -@Suite("Dormant D024 upload attestation foundation (M5)") +@Suite("D024 foreground upload-only contract (M5)") struct DiagnosticsUploadM5Tests { @Test("Go and Swift reproduce the exact request, query, and attestation bytes") func crossLanguageGoldenBytes() throws { @@ -32,6 +33,101 @@ struct DiagnosticsUploadM5Tests { ) } + @Test("Production parser accepts the exact Go vectors and preserves upload-only evidence") + func productionWireAndAcceptance() throws { + let fixture = try M5UploadFixtureLoader.load() + let golden = try M5UploadGoldenMessages.make(fixture) + let record = try makeProductRecord(fixture) + let request = try DiagnosticsUploadProtocol.decode(golden.request, record: record) + let query = try DiagnosticsUploadProtocol.decode(golden.query, record: record) + let attestation = try DiagnosticsUploadProtocol.decode(golden.attestation, record: record) + #expect(request.body.m1Hex == fixture.requestBodyHex) + #expect(request.digest.m1Hex == fixture.requestDigestHex) + #expect(query.body.m1Hex == fixture.queryBodyHex) + #expect(query.digest.m1Hex == fixture.queryDigestHex) + #expect(attestation.body.m1Hex == fixture.attestationBodyHex) + #expect(attestation.digest.m1Hex == fixture.attestationDigestHex) + + let installation = try Data(m1Hex: fixture.installationBindingHex) + let operationID = try Data(m1Hex: fixture.operationIdHex) + let operation = DiagnosticsUploadProtocol.Operation( + request: request, + query: query, + operationID: operationID, + installationBinding: installation, + requestComponents: try DiagnosticsNamespaceProtocol.operationRequestComponents( + installationBinding: installation, + operationID: operationID + ) + ) + let accepted = try DiagnosticsUploadProtocol.validateUploadAttestation( + golden.attestation, + operation: operation, + record: record, + now: Date(timeIntervalSince1970: TimeInterval(fixture.attestationIssuedAt)) + ) + #expect(accepted == attestation) + + var tampered = golden.attestation + tampered[tampered.index(before: tampered.endIndex)] ^= 0x01 + #expect(throws: DiagnosticsProtocolError.invalidMessage) { + _ = try DiagnosticsUploadProtocol.validateUploadAttestation( + tampered, + operation: operation, + record: record, + now: Date(timeIntervalSince1970: TimeInterval(fixture.attestationIssuedAt)) + ) + } + } + + @Test("Production request creation is exclusive, confined, and collision-safe") + func productionImmutableRequestStore() throws { + let fixture = try M5UploadFixtureLoader.load() + let golden = try M5UploadGoldenMessages.make(fixture) + let installation = try Data(m1Hex: fixture.installationBindingHex) + let operationID = try Data(m1Hex: fixture.operationIdHex) + let components = try DiagnosticsNamespaceProtocol.operationRequestComponents( + installationBinding: installation, + operationID: operationID + ) + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("vaultsync-upload-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: folder) } + let operations = components.dropLast().reduce(folder) { + $0.appendingPathComponent($1, isDirectory: true) + } + try FileManager.default.createDirectory(at: operations, withIntermediateDirectories: true) + + try DiagnosticsUploadFileStore.createImmutable( + folderPath: folder.path, + components: components, + data: golden.request + ) + #expect( + try DiagnosticsNamespaceFileReader.read( + folderPath: folder.path, + components: components + ) == golden.request + ) + #expect(throws: DiagnosticsProtocolError.conflict) { + try DiagnosticsUploadFileStore.createImmutable( + folderPath: folder.path, + components: components, + data: golden.request + ) + } + + let requestURL = components.reduce(folder) { $0.appendingPathComponent($1) } + let linked = folder.appendingPathComponent("second-link.cbor") + try FileManager.default.linkItem(at: requestURL, to: linked) + #expect(throws: DiagnosticsProtocolError.conflict) { + _ = try DiagnosticsNamespaceFileReader.read( + folderPath: folder.path, + components: components + ) + } + } + @Test("Only a pinned local response for the exact active query sets upload") func exactPinnedAcceptanceOnly() throws { let fixture = try M5UploadFixtureLoader.load() @@ -230,7 +326,7 @@ struct DiagnosticsUploadM5Tests { } } - @Test("M5 transfer remains absent from Swift product code and records no forbidden operation values") + @Test("Product upload runtime remains isolated from response, durable state, and external systems") func privacyAndProductBoundary() throws { let fixture = try M5UploadFixtureLoader.load() let testDirectory = URL(fileURLWithPath: "\(#filePath)").deletingLastPathComponent() @@ -243,22 +339,45 @@ struct DiagnosticsUploadM5Tests { while let url = enumerator?.nextObject() as? URL { guard url.pathExtension == "swift" else { continue } let body = try String(contentsOf: url, encoding: .utf8) - if body.contains(M5UploadMessage.capability) { - #expect(url.lastPathComponent == "DiagnosticsCapabilityNamespaceProtocol.swift") - } - for transferDomain in [ + for uploadDomain in [ "eu.vaultsync.roundtrip/v1/operation-request", "eu.vaultsync.roundtrip/v1/attestation-query", "eu.vaultsync.roundtrip/v1/upload-attestation", + ] where body.contains(uploadDomain) { + #expect(url.lastPathComponent == "DiagnosticsUploadProtocol.swift") + } + for laterDomain in [ "eu.vaultsync.roundtrip/v1/response-authorization", "eu.vaultsync.roundtrip/v1/response-artifact", "eu.vaultsync.roundtrip/v1/cleanup-request", "eu.vaultsync.roundtrip/v1/cleanup-ack", ] { - #expect(!body.contains(transferDomain)) + #expect(!body.contains(laterDomain)) + } + if [ + "DiagnosticsUploadProtocol.swift", + "DiagnosticsUploadPreflight.swift", + "DiagnosticsUploadFileStore.swift", + ].contains(url.lastPathComponent) { + for forbiddenSink in [ + "UserDefaults", "Keychain", "StoreKit", "APNs", "Cloud Relay", + "Logger(", "os_log", "crash report", "support bundle", + ] { + #expect(!body.contains(forbiddenSink)) + } } } + let controlledView = try String( + contentsOf: productDirectory + .appendingPathComponent("Views", isDirectory: true) + .appendingPathComponent("ControlledDiagnosticsView.swift"), + encoding: .utf8 + ) + #expect(controlledView.contains("@Environment(\\.scenePhase)")) + #expect(controlledView.contains("if phase != .active")) + #expect(controlledView.components(separatedBy: "cancelAllForegroundUploads()").count >= 3) + let allowedSnapshot = "phase=completed upload=true download=false roundtrip=false cleanup=0" for forbidden in [ fixture.operationIdHex, @@ -304,6 +423,53 @@ struct DiagnosticsUploadM5Tests { helperPublicKey: helperPrivate.publicKey ) } + + private func makeProductRecord(_ fixture: M5UploadFixture) throws -> DiagnosticsPairingRecord { + let appSeed = try Data(m1Hex: fixture.appSeedHex) + let helperSeed = try Data(m1Hex: fixture.helperSeedHex) + let appKey = try Curve25519.Signing.PrivateKey(rawRepresentation: appSeed) + let helperKey = try Curve25519.Signing.PrivateKey(rawRepresentation: helperSeed) + let appPublic = appKey.publicKey.rawRepresentation + let helperPublic = helperKey.publicKey.rawRepresentation + let appKeyID = DiagnosticsCrypto.keyID(publicKey: appPublic) + let helperKeyID = DiagnosticsCrypto.keyID(publicKey: helperPublic) + return DiagnosticsPairingRecord( + id: DiagnosticsPairingRecord.identifier( + appKeyID: appKeyID, + folderBinding: try Data(m1Hex: fixture.folderBindingHex) + ), + homeserverDeviceID: "P56IOI7-MZJNU2Y-IQGDREY-DM2MGTI-MGL3BXN-PQ6W5BM-TBBZ4TJ-XZWICQ2", + folderID: "fixture-folder", + endpointHost: "127.0.0.1", + endpointPort: 443, + tlsSPKIPin: Data(repeating: 0x55, count: 32), + helperPublicKey: helperPublic, + helperKeyID: helperKeyID, + homeserverBinding: try Data(m1Hex: fixture.homeserverBindingHex), + folderBinding: try Data(m1Hex: fixture.folderBindingHex), + appSeed: appSeed, + appPublicKey: appPublic, + appKeyID: appKeyID, + appEpoch: fixture.appEpoch, + helperEpoch: fixture.helperEpoch, + currentCredentialStateDigest: Data(repeating: 0x25, count: 32), + state: .namespaceActive, + hardExpiry: fixture.expiresAt, + localDeadline: nil, + lastOutgoing: Data([0xa0]), + lastIncoming: Data([0xa0]), + transcriptFingerprint: nil, + namespaceID: Data(repeating: 0x07, count: 32), + namespaceInitialAppKeyID: appKeyID, + namespaceEnablement: Data([0xa0]), + namespaceRootDigest: Data(repeating: 0x21, count: 32), + namespaceManifestDigest: Data(repeating: 0x22, count: 32), + namespaceManifestEpoch: fixture.helperEpoch, + namespaceAuthorizationDigest: Data(repeating: 0x23, count: 32), + namespaceAuthorizationEpoch: fixture.authorizationEpoch, + pendingLifecycle: nil + ) + } } private struct M5DeterministicGenerator: RandomNumberGenerator { diff --git a/notify/diagnostics_contract_model_test.go b/notify/diagnostics_contract_model_test.go index 571070d..2c211e2 100644 --- a/notify/diagnostics_contract_model_test.go +++ b/notify/diagnostics_contract_model_test.go @@ -292,6 +292,7 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) capabilityProtocolCarrier := filepath.Join(repoRoot, "notify", "diagnostics_capability_protocol.go") 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") runtimeRoots := []string{ filepath.Join(repoRoot, "notify"), filepath.Join(repoRoot, "ios", "VaultSync"), @@ -316,7 +317,7 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) allowed := path == dormantCapabilityCarrier || path == capabilityProtocolCarrier || (name == "pairing" && (path == pairingProtocolCarrier || path == appPairingProtocolCarrier)) || (name == "namespace" && (path == namespaceProtocolCarrier || path == appCapabilityNamespaceCarrier)) || - (name == "roundtrip" && path == appCapabilityNamespaceCarrier) + (name == "roundtrip" && (path == appCapabilityNamespaceCarrier || path == appUploadProtocolCarrier)) if bytes.Contains(body, []byte(capability)) && !allowed { return fmt.Errorf("runtime file %s contains an unapproved diagnostics capability %s", path, name) } @@ -329,7 +330,9 @@ func TestDiagnosticsRuntimeCarrierIsExplicitAndCoreRemainsIsolated(t *testing.T) (strings.HasPrefix(name, "roundtrip.") && (path == uploadProtocolCarrier || path == responseProtocolCarrier || path == capabilityProtocolCarrier)) || ((name == "roundtrip.capability_query" || name == "roundtrip.capability_response") && - path == appCapabilityNamespaceCarrier) + path == appCapabilityNamespaceCarrier) || + ((name == "roundtrip.operation_request" || name == "roundtrip.attestation_query" || + name == "roundtrip.upload_attestation") && path == appUploadProtocolCarrier) if bytes.Contains(body, []byte(strings.TrimSuffix(domain, "\x00"))) && !allowed { return fmt.Errorf("runtime file %s contains an unapproved signature domain", path) } From 9f795d0011084463a210088f1c4efdde3207f8d6 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 02:47:50 +0200 Subject: [PATCH 2/4] Document foreground upload-only boundaries Describe explicit consent, retention, compatibility, rollback, and the separate upload, download, and roundtrip evidence states. Record local simulator and isolated Syncthing evidence while keeping the signed owner-device gate explicitly pending. --- PRIVACY.md | 51 +++- ...-capability-pairing-namespace-readiness.md | 15 +- docs/architecture.md | 59 ++-- docs/m5-upload-attestation-readiness.md | 252 ++++++++++++------ 4 files changed, 266 insertions(+), 111 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index af97685..587bb17 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -77,8 +77,10 @@ file or vault path, file content, or diagnostic check identifier. The published helper 2.0.2 contains an optional local runtime for the authenticated diagnostics protocol. Publication or installation alone does not -activate it, and the currently released app does not call it. The runtime starts -only when an operator separately +activate it, and the currently released app does not call it. Unreleased app +source contains the separately user-initiated upload-only flow described below; +it still cannot activate an unconfigured helper. The runtime starts only when +an operator separately supplies both a read-only diagnostics configuration and a writable private state directory; otherwise it creates no listener, credential, mapping, namespace, or artifact. @@ -132,8 +134,9 @@ 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; product upload, download, and roundtrip evidence remain -unset. +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. 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 @@ -229,6 +232,46 @@ conflicts, history, or tombstones. Lost-key recovery deliberately requires new pairing and a separate operator revocation of the surviving old authorization. See [app capability, pairing, and namespace readiness](docs/app-capability-pairing-namespace-readiness.md). +### Explicit Foreground Upload Check (Unreleased Source) + +After separate pairing, capability negotiation, app consent, operator namespace +creation, and helper-countersigned authorization, the unreleased app source +offers a distinct foreground upload check. It starts only after a new user tap +and confirmation. Opening Settings, upgrading or launching the app, checking +capability, ordinary synchronization, Relay/APNs activity, or background +execution never starts it. + +The app revalidates the exact existing settled `sendreceive` folder, one +designated connected and unpaused peer, current engine generation, authenticated +namespace, expanded Syncthing ignore behavior, and an empty operation slot. It +does not discover, add, share, pause, reconfigure, trust, or create any +Syncthing folder, peer, namespace, or ignore rule. If any precondition fails, +no operation artifact is created. + +For one accepted start, the app generates a random operation identifier, two +random nonces, and exactly 256 random request bytes in memory. It exclusively +creates one app-signed request in the already authorized visible namespace, +rescans only the selected folder, and sends one app-signed query byte-for-byte +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. + +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 +cleanup does not promise removal of those retained copies; they never regain +validity or become evidence. + +See [M5 foreground upload-only readiness](docs/m5-upload-attestation-readiness.md). + ### Data Security - APNs device tokens are encrypted at rest (AES-256-GCM) diff --git a/docs/app-capability-pairing-namespace-readiness.md b/docs/app-capability-pairing-namespace-readiness.md index aa5f129..fd73acd 100644 --- a/docs/app-capability-pairing-namespace-readiness.md +++ b/docs/app-capability-pairing-namespace-readiness.md @@ -4,7 +4,10 @@ plane is implemented and locally verified. It is not an App Store release or a transfer milestone. The published helper baseline is `notify-v2.0.2`; the app change remains unreleased until its own PR and later release gates complete. -Upload, download, and roundtrip evidence are all unset. +This document records the M3 control-plane boundary. The later unreleased M5 +source adds only the explicit foreground upload leg documented in +[M5 foreground upload-only readiness](m5-upload-attestation-readiness.md); +download and roundtrip remain unset. ## User-controlled scope @@ -53,10 +56,12 @@ cookies, cache, compression, query, or fragment, fixed CBOR media types and body limits, and mutually authenticated application signatures. Network errors become `capability unavailable`; authenticated protocol, tuple, or mandatory-flag mismatches become `unsupported`. Neither state falls back to a -weaker success. Only the four fixed M3 pairing, capability, namespace- -enablement, and namespace-authorization paths are accepted. A successful -capability response can authorize the next explicit control step only through -its exact signed expiry; it is invalidated on restart, error, or credential +weaker success. M3 accepts only its four fixed pairing, capability, +namespace-enablement, and namespace-authorization paths. The separate M5 +source additionally permits only the fixed Decision 024 attestation path; it +does not permit response-authorization or cleanup calls. A successful +capability response can authorize the next explicit action only through its +exact signed expiry; it is invalidated on restart, error, or credential transition. Every persisted pending D022/D023 operation also carries an app-local diff --git a/docs/architecture.md b/docs/architecture.md index 128ccb6..16ca5cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,18 +46,18 @@ succeeded” flag: | Silent push received locally | This iPhone, unattributed | The iOS remote-notification delegate | | 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 confirmed | Server/folder/check correlation | Not available with the current helper | -| Download confirmed | Server/folder/check correlation | Not available as a controlled directional proof with the current helper | -| Full roundtrip confirmed | One matching upload-then-download correlation | Not available with the current helper | +| 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. | 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 and controlled -download therefore remain independent and unset, so a full roundtrip cannot be -derived. +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. Server snapshots contain only entitlement, provisioning, backend, and per-homeserver Relay observation. The v1 push contains no homeserver/folder @@ -67,7 +67,7 @@ check can scope fresh local evidence to one folder and its sole configured peer. #### Manual synchronization-path check -Relay Diagnostics exposes the only entry point. An explicit tap takes a current +Relay Diagnostics exposes the passive-check entry point. An explicit tap takes a current subscription-local event cursor, nanosecond production-time boundary, and engine generation, then observes at most five times with 2/4/8/12-second delays. Results are kept in memory per unique server/folder pair. Only an unpaused `sendreceive` @@ -86,20 +86,42 @@ 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. A controlled upload/download roundtrip needs a -separately designed, additive, capability-negotiated helper contract and a -demonstrably safe app-owned diagnostics namespace. Relay v1 is unchanged by this -milestone. +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. -#### Opt-in correlated-roundtrip helper runtime — no app evidence yet +#### Opt-in correlated-roundtrip helper runtime — foreground upload only [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. VaultSync 2.0 remains NO-GO; product upload, controlled download, and -causal roundtrip evidence are all unset. +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 +remains NO-GO. + +One upload operation begins only after a user tap and a second localized +confirmation. The app rechecks the exact current capability, pairing and +namespace authorization, settled `sendreceive` folder, one designated connected +unpaused peer, engine generation, path overlap, Syncthing ignore behavior, and +empty operation slot. It never discovers or configures a peer, share, folder, +namespace, trust decision, or ignore. A failed preflight creates nothing. + +The app generates a fresh operation ID, two nonces, and exactly 256 random +request bytes in memory; signs Decision 024 types 3 and 4; exclusively creates +the exact request beneath the already authorized installation; and rescans only +the selected folder. It sends one byte-identical signed query on the fixed eight +poll schedule. Only an exact type-5 response from the TLS-pinned paired helper, +with every key, epoch, binding, operation, digest, nonce, signature, and clock +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 +immutable false in this milestone. The runtime is gated by an operator-authored read-only configuration plus a separate writable state directory. If either is absent, existing helpers retain @@ -192,16 +214,19 @@ 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, but signatures establish only authorship and causal +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, -production rollout, rollback, and then separate app milestones remain mandatory. +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). The app-side scope, compatibility, persistence, consent, and rollback boundaries are documented in -[app capability, pairing, and namespace readiness](app-capability-pairing-namespace-readiness.md). +[app capability, pairing, and namespace readiness](app-capability-pairing-namespace-readiness.md) +and [M5 foreground upload-only readiness](m5-upload-attestation-readiness.md). ### Connection paths & iOS network privacy diff --git a/docs/m5-upload-attestation-readiness.md b/docs/m5-upload-attestation-readiness.md index 47d1cdc..0afb533 100644 --- a/docs/m5-upload-attestation-readiness.md +++ b/docs/m5-upload-attestation-readiness.md @@ -1,94 +1,176 @@ -# M5 dormant upload-attestation readiness +# M5 foreground upload-only readiness ## Status and claim boundary -M5 is an internal, upload-only implementation foundation for Decision 024. It -does not authorize or provide helper runtime, App runtime, capability -negotiation, packaging, publication, rollout, controlled download, or causal -roundtrip. VaultSync 2.0 remains **NO-GO**. +M5 implements the owner-authorized, explicit foreground upload leg of Decision +024 in unreleased app source. It uses the already published helper 2.0.2 +runtime. It does not implement controlled download, causal roundtrip, app-side +authenticated cleanup, background execution, automatic discovery, or any +Relay carrier. VaultSync 2.0 remains **NO-GO** until the later milestones and +release gates complete. | Evidence field | M5 state | Strongest permitted claim | |---|---|---| -| Upload | Test/mock only | Exact fresh app-authored request bytes became readable through the confined namespace to the paired test helper, whose exact signed attestation was accepted from the pinned mock channel for the active query. | -| Download | Unset | Not implemented and not inferred from a synchronized attestation copy, file presence, time, HTTP, scan, index, idle, completion, or cleanup. | -| Roundtrip | Unset | Cannot be derived without a separately approved controlled download for the same causal chain. | -| Authenticated correlation | Test/mock only | Exact app/helper keys and epochs, homeserver/folder binding, operation, request/payload/query digests, nonces, TTL, signatures, and active byte-identical query. | - -There is no global success flag. State and limits are scoped to the exact -app/homeserver/folder/helper/key-epoch tuple and operation. Cleanup state is -orthogonal to evidence. - -## Implemented boundary - -- Decision 024 deterministic CBOR messages 3–5 only: `operation_request`, - `attestation_query`, and `upload_attestation`. -- Exact 256-byte request payload, SHA-256 domain/payload digests, Ed25519 - signatures and key IDs, nonzero operation/nonces, exact bindings/epochs, and - 600-second TTL with 120-second wall-clock skew. -- Helper reads the complete immutable request only through the M4 confined root - handle after validating the current authenticated namespace authorization. -- The helper creates an anonymous inode, writes and fsyncs the complete - attestation, links it once to the final filename, fsyncs the directory, then - returns the same persisted bytes. Partial final names and overwrite are - impossible on the supported Linux filesystem path. -- Repeated exact queries and helper restart return only the exact authenticated - persisted attestation. A different query, binding, epoch, key, operation, - digest, signature, payload, clock, or conflicting artifact cannot upgrade. -- Fixed limits: one active operation per tuple, two in the Swift app test model, - eight helper-wide, eight polls, three starts/hour and twelve/day per - app/folder, sixty starts/day helper-wide, 30 direct requests/minute per paired - app, and 120/minute helper-wide. Invalid requests count. -- Swift parsing and upload acceptance are test-target-only and require the exact - pinned mock channel plus the exact active query. Pending, HTTP acceptance, - reachability, timestamps, and synchronized copies do not set upload. - -Not implemented: capability query/response, any flags, local endpoint, TLS, -listener, response authorization, response artifact, authenticated cleanup -messages, download evidence, roundtrip, product UI, automatic discovery, -namespace creation, pairing/trust adoption, or durable operation history. - -## Compatibility matrix +| Upload | Implemented in production app source; local product, cross-language, Linux helper, and isolated two-Syncthing-instance tests pass | Fresh app-authored random request bytes became readable to the exactly paired helper in the selected logical folder only after the app accepts the exact helper-signed attestation for its active byte-identical pinned query. | +| Download | Unset | No response authorization, response baseline, response-file acceptance, or fresh exact `ItemFinished` exists in this milestone. | +| Roundtrip | Unset | No same-chain controlled download exists, so no roundtrip can be derived. | +| Authenticated correlation | Implemented for upload only | Exact app/helper keys and epochs, homeserver/folder binding, namespace authorization, operation, request/payload/query digests, nonces, TTL, signatures, and active query are validated. | +| Cleanup | Evidence-orthogonal helper foundation only | M5 neither derives evidence from cleanup nor adds the later app cleanup workflow. Live and retained copies may remain. | + +There is no global success flag. Upload, download, and roundtrip are separate +fields. A helper signature proves helper authorship and the signed causal +bindings, not a transport route, direct peer, exact network bytes, block +provenance, future delivery, or global sync health. + +## Explicit product flow + +The only product entry point is the Controlled Diagnostics view. Opening the +view, installing or upgrading the app, checking capability, receiving a Relay +wake-up, or running ordinary/background sync creates no operation. One upload +operation requires a separate user tap and a localized confirmation that +discloses the 256 random bytes and possible retained opaque copies. + +Before creating an artifact the app requires all of the following: + +- one active D022 pairing and current D023 namespace authorization; +- a fresh helper-signed capability response with all Decision 024 flags; +- the exact settled existing folder mapping selected during pairing; +- exactly one designated, connected, unpaused peer; +- an unpaused, healthy `sendreceive` folder with no path overlap; +- a running unchanged embedded Syncthing engine generation; +- the real Syncthing ignore matcher allowing the fixed namespace and all three + exact operation filenames; +- existing, non-symlink fixed namespace directories and no request, + attestation, or response collision for the fresh operation; and +- the tuple, process-concurrency, hourly, daily, and direct-request limits. + +Failures before that boundary create no artifact. The preflight is read-only: +it never adds a peer/share/folder, creates a namespace, changes mode, pause +state, paths, ignores, discovery, trust, or Syncthing configuration. + +The active operation then: + +1. generates a fresh nonzero 32-byte operation ID, request nonce, query nonce, + and exactly 256 random payload bytes in memory; +2. signs deterministic Decision 024 types 3 and 4 and derives the request + filename internally as lowercase unpadded base32; +3. walks the already existing namespace with descriptor-relative + `O_NOFOLLOW` opens, creates the request with `O_EXCL`, writes and fsyncs the + exact bytes, verifies regular-file identity, single link, size, and bytes, + then fsyncs the parent; +4. requests one rescan of only the already selected folder; +5. retransmits the one signed query byte-for-byte on at most eight fixed polls + (`2, 4, 8, 16, 30, 60, 120, 120` seconds); +6. before every poll revalidates the complete persisted pairing record, engine, + folder, peer, mapping, ignores, authenticated namespace, and exact persisted + request bytes; and +7. sets only `upload observed` after the pinned TLS endpoint returns an exact + canonical helper-signed type-5 attestation binding the active request, + payload digest, query nonce/digest, keys, epochs, homeserver/folder, operation, + and active clock window. + +HTTP 200/202, reachability, request creation, rescan, index activity, folder +completion, timestamps, `idle`, and a synchronized attestation copy are not +upload evidence. The product path contains no Decision 024 type 6–9 domains. + +## Lifecycle, limits, and terminal truth + +Active correlation is memory-only. Leaving the view, explicit cancellation, +controller refresh, app restart, engine-generation change, mapping/peer/mode/ +ignore/access change, credential transition, protected-data loss, timeout, or +conflict ends the operation. A run token prevents a cancelled or pre-refresh +task from writing into later controller state. Cancellation is rechecked after +every asynchronous endpoint call, so a late valid attestation cannot upgrade a +terminal operation. + +The app enforces one active operation per exact +app/homeserver/folder/helper/key/namespace-authorization tuple, two process-wide, +three starts/hour and twelve/day per app/folder while the process remains +alive, thirty direct requests/minute per paired record, and 120/minute +process-wide. The helper independently enforces the same per-app/folder start +windows plus its sixty-start/day and eight-active-operation limits. No network +input can raise a limit. + +The request is immutable and is deliberately not removed on an ambiguous +failure. A crash after its exclusive publication can therefore leave a valid +or partial collision; a later operation uses a fresh ID and never resumes that +proof. The helper rejects incomplete or conflicting bytes. This is safer than +overwriting or broadly deleting an unverified synchronized entry. + +## Compatibility and existing-user behavior + +The contract is additive and Trigger v1/Relay v1 remain unchanged. | App | Helper | Result | |---|---|---| -| Current product app | Current product helper | Trigger v1, Cloud Relay v1, passive/local evidence, and all existing setup behavior remain unchanged. No M5 code is called. | -| Current product app | Source tree containing M5 | Same behavior. The helper entry point, installers, Compose, image, and binaries do not reference M5 and advertise no capability. | -| M5 Swift test client | Current product helper | Unsupported/capability unavailable; no artifact and no fallback evidence. | -| M5 Swift test client | M5 in-process mock attestor with authenticated M4 fixture | Upload-only test evidence after exact signed attestation; download and roundtrip remain unset. | -| Any old/new product combination after source revert | Any old/new product combination | Existing wire behavior is unchanged. Product state needs no migration. Opaque test artifacts or user-controlled backup/version/tombstone copies may remain but are ignored and never regain validity. | -| Rotated/revoked/lost key or changed epoch/binding | M5 mock attestor | Fail closed; explicit re-pair/re-authorization is required by the dormant M3/M4 foundations. No automatic trust transfer or proof resumption. | - -## Namespace, retention, and cleanup - -The live request and attestation are exact bounded operation artifacts under one -authenticated installation. M4 cleanup deletes only a previously owned file -whose descriptor-relative identity and digest still match. It never changes -upload/download/roundtrip fields and never targets the root, README, manifests, -authorization, credentials, unverified entries, user files, backups, -`.stversions`, conflict copies, remote history, or tombstones. Retained opaque -copies can outlive TTL but cannot be accepted by a later operation. - -The Decision 024 authenticated cleanup protocol is deliberately absent and must -be implemented in its own later milestone together with response foundations. - -## Privacy and external systems - -M5 has no logger, telemetry, crash annotation, support export, UserDefaults, -Keychain, credential database, Relay, APNs, StoreKit, Trigger, status, probe, or -Syncthing runtime client. Contract values live only in test memory and the -disclosed namespace artifacts. The local E2E uses two temporary Syncthing homes -and folders with discovery, Relay, NAT, upgrades, usage reporting, and crash -reporting disabled inside a network-isolated container. It changes no installed -Syncthing configuration and contacts no production service. - -## Rollback and next gate - -M5 rollback is a source revert: no product migration or wire rollback is -required because no runtime calls the code and no release or deployment exists. -Test artifacts are removed with temporary directories; user-controlled copies -would follow the retention rules above. - -After M5 merge, Decisions 021–025 must be compared against the actual code. -Decision 025 does not authorize Helper/App runtime or Phase B. A separate owner -scope approval is required before the dormant response/cleanup foundation, and -every subsequent milestone retains its own PR and owner merge gate. +| Released old app | Old helper | Existing sync and Relay behavior only. | +| Released old app | Published helper 2.0.2 | Helper remains dormant for the app; no operation or namespace is created automatically. | +| M5-capable app | Old or disabled helper | `Capability unavailable`; no artifact, fallback, or weaker evidence. | +| M5-capable app | Enabled helper 2.0.2 but unpaired/unauthorized | Explicit pairing/namespace guidance only; no artifact or trust adoption. | +| M5-capable app | Paired and namespace-authorized helper 2.0.2, ineligible folder/peer | `Unsupported` or `unavailable`; ordinary sync is unchanged. | +| M5-capable app | Paired and namespace-authorized helper 2.0.2, exact eligible target | A user may start one foreground upload-only operation. Download and roundtrip stay unset. | +| App downgrade | Helper 2.0.2 | Old app ignores additive state; no operation resumes and no credential, namespace, mapping, or user data is deleted. | +| Helper downgrade | M5-capable app | Capability becomes unavailable; no new request and no fallback success. | +| Re-upgrade | Capable app/helper | Fresh capability, pairing epochs, mapping, and namespace authorization are revalidated; no old operation resumes. | + +An existing-user app upgrade performs only the prior read-only credential +inspection when the user opens Controlled Diagnostics. It does not create a +key, pair, trust, namespace, peer, share, artifact, rescan, or configuration +change without the explicit actions above. + +## Namespace, retention, privacy, and rollback + +Operation IDs, nonces, random payloads, digests, message bytes, per-operation +status, and evidence are not written to UserDefaults, Keychain, logs, +telemetry, crash annotations, support bundles, Relay, APNs, StoreKit, or durable +diagnostic history. Pairing records retain no operation state. Fixed endpoint +paths contain no identifier. + +The request and the helper's attestation are visible synchronized files in +`VaultSync Diagnostics`. Peers, backups, `.stversions`, conflict copies, +remote history, deletion records, and tombstones may retain opaque copies after +TTL, cancellation, rollback, or live cleanup. Such copies never regain +validity and cannot set evidence. App rollback stops the entry point but does +not delete credentials, authorization history, namespace content, mappings, or +user data. Helper rollback makes capability unavailable and likewise preserves +those objects. Forward recovery starts from a fresh capability check and never +from an old operation. + +## Verification + +All Xcode results and derived data are outside the repository under `/tmp`. +The current local gate includes: + +- `go test -tags noassets ./bridge -count=1`: passed, including the real + Syncthing ignore/access/collision preflight; +- `go test ./... -count=1` in `notify/` on macOS: passed; +- the same complete Notify suite in a digest-pinned, read-only Linux container + with no network and all capabilities dropped: passed; +- `TestDiagnosticsUploadThroughTwoEphemeralSyncthingInstances` in that isolated + Linux container: passed with two fresh Syncthing homes, exact request + propagation, confined helper read, persisted exact signed attestation, pinned + mock-channel upload acceptance, and rejection of the synchronized + attestation copy as upload evidence; +- focused production Swift suites on an iPhone 17 Pro simulator: 12 passed, + zero failed/skipped; +- the complete iOS test plan on that simulator: 429 tests / 436 parameterized + test runs passed, zero failed/skipped; and +- generic iOS Simulator product build, design-token lint, localized string-key + parity, and localized plist validation: passed. + +The Swift product runner tests cover exact Go golden bytes, canonical and +signature tampering, exclusive/symlink/hard-link/collision handling, explicit +preflight, byte-identical polling, upload-only evidence, late response after +cancellation, refresh non-resumption, three/hour rate rejection before a fourth +write, finite eight-poll timeout, and no artifact/request after an ambiguous +peer preflight. + +The signed owner-device test remains a merge gate. It is not claimed complete +until Xcode can mount its Developer Disk Image and the structured `/tmp` +result reports the focused suites passing. A simulator, build success, or +device discovery alone is not real-device evidence. + +Decision 024 remains the unchanged canonical contract. The next milestone may +add controlled download only after this upload-only PR, its review/CI gates, +and its real-device evidence are complete. Roundtrip remains a still later, +separate derivation. From b2442fd1a04296d4cf5eef0e40b04da8e7804f32 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 09:35:26 +0200 Subject: [PATCH 3/4] docs(m5): record owner physical-device waiver Replace the pending signed owner-device merge gate with the explicit owner-approved physical-device waiver for the remaining 2.0 run. Record the fresh exact-head substitute evidence: complete simulator plan, focused upload suites, Release-configuration simulator build, and the isolated two-instance Syncthing E2E. No real-device, keychain, APNs, background-wake, or TestFlight-hardware claim is added. --- docs/m5-upload-attestation-readiness.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/m5-upload-attestation-readiness.md b/docs/m5-upload-attestation-readiness.md index 0afb533..94d7f2a 100644 --- a/docs/m5-upload-attestation-readiness.md +++ b/docs/m5-upload-attestation-readiness.md @@ -155,8 +155,9 @@ The current local gate includes: zero failed/skipped; - the complete iOS test plan on that simulator: 429 tests / 436 parameterized test runs passed, zero failed/skipped; and -- generic iOS Simulator product build, design-token lint, localized string-key - parity, and localized plist validation: passed. +- generic iOS Simulator product build, a Release-configuration simulator + build, design-token lint, localized string-key parity, and localized plist + validation: passed. The Swift product runner tests cover exact Go golden bytes, canonical and signature tampering, exclusive/symlink/hard-link/collision handling, explicit @@ -165,12 +166,17 @@ cancellation, refresh non-resumption, three/hour rate rejection before a fourth write, finite eight-poll timeout, and no artifact/request after an ambiguous peer preflight. -The signed owner-device test remains a merge gate. It is not claimed complete -until Xcode can mount its Developer Disk Image and the structured `/tmp` -result reports the focused suites passing. A simulator, build success, or -device discovery alone is not real-device evidence. +The signed owner-device test was not executed — owner-approved +physical-device waiver (2026-07-15). For the remaining 2.0 completion run the +owner explicitly replaced the real-device merge gate with fresh exact-head +substitute evidence: the complete iOS plan and the focused upload suites +re-run on the iPhone 17 Pro simulator, a Release-configuration simulator +build, and the isolated two-instance Syncthing E2E above. No hardware +keychain behavior, real APNs delivery, real background waking, or TestFlight +installation on hardware is claimed; simulator evidence is never described as +real-device evidence. Decision 024 remains the unchanged canonical contract. The next milestone may add controlled download only after this upload-only PR, its review/CI gates, -and its real-device evidence are complete. Roundtrip remains a still later, -separate derivation. +and its owner-waived substitute evidence gates are complete. Roundtrip +remains a still later, separate derivation. From 55019a606470b3f50e9a55234aba7c1c4f0f7b12 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Wed, 15 Jul 2026 09:57:17 +0200 Subject: [PATCH 4/4] fix(m5): tighten failure semantics and unlink safety Address the six review findings on the upload-only PR: - Suppress the generic error banner once the request artifact exists; the phase carries the truthful state, so the UI can no longer claim nothing was created after an artifact was written. - Terminate invalid pinned-channel protocol messages as unsupported; conflict stays reserved for unexpected authenticated namespace content per Decision 024. - Unlink a failed request file only after fstatat proves the directory entry still is the exact inode this invocation created, so a rename or replacement by Syncthing can never lose an unrelated entry. - Add DiagnosticsPairingController.swift to the forbidden-sink scan. - Count the view's cancellation hooks explicitly (two lifecycle hooks). - Scope the M3 document's upload claims and fix the Go gate command. --- ...-capability-pairing-namespace-readiness.md | 4 ++-- docs/m5-upload-attestation-readiness.md | 4 ++-- .../DiagnosticsPairingController.swift | 14 ++++++++++--- .../Services/DiagnosticsUploadFileStore.swift | 21 ++++++++++++++++--- .../DiagnosticsUploadM5Tests.swift | 5 ++++- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/app-capability-pairing-namespace-readiness.md b/docs/app-capability-pairing-namespace-readiness.md index fd73acd..0fd9e91 100644 --- a/docs/app-capability-pairing-namespace-readiness.md +++ b/docs/app-capability-pairing-namespace-readiness.md @@ -121,7 +121,7 @@ The diagnostics contract is additive. Trigger v1 and Relay v1 are unchanged. | M3-capable app | Helper 2.0.2, diagnostics unset | Any Relay v1 | `Capability unavailable`; Trigger v1 remains unchanged. | | M3-capable app | Helper 2.0.2, enabled but unpaired | Any Relay v1 | Explicit QR pairing is offered; no trust or namespace is inherited. | | M3-capable app | Helper 2.0.2, paired but namespace absent | Any Relay v1 | Authenticated capability can succeed; upload, download, and roundtrip remain unset. | -| M3-capable app | Helper 2.0.2, explicitly namespace-authorized | Existing or new Relay v1 | D022/D023 control plane active; no transfer artifact exists in this milestone. | +| M3-capable app | Helper 2.0.2, explicitly namespace-authorized | Existing or new Relay v1 | D022/D023 control plane active; the M3 control plane itself creates no transfer artifact (the unreleased M5 source adds only the explicit foreground upload leg). | | App downgrade | Helper 2.0.2 | Any Relay v1 | Old app ignores the additive records; helper stays dormant for it; credentials and namespace copies are retained. | | App re-upgrade | Helper 2.0.2 | Any Relay v1 | Read-only reconstruction, fresh capability, and current namespace authorization are required; no operation resumes. | @@ -138,7 +138,7 @@ explicit pairing state machine. This is control-plane evidence only. | Authenticated capability | Implemented in production app source; cross-language vectors and a deterministic pinned-transport harness pass. Real-device/helper deployment evidence remains unset. | | Pairing | Explicit, fingerprint-confirmed, scoped, restart-safe D022 state machine. | | Namespace | Explicit app request plus separate operator creation and helper-countersigned D023 authorization. | -| Upload | Unset; no request artifact is created by this milestone. | +| Upload | Unset at this M3 boundary; the M3 control plane creates no request artifact. The unreleased M5 source adds only the explicit foreground upload leg (see the M5 readiness document). | | Download | Unset; no response artifact or fresh `ItemFinished` baseline exists. | | Roundtrip | Unset; no same-chain directional evidence exists. | | Cleanup | No app cleanup runtime in this milestone; helper foundation remains evidence-orthogonal. | diff --git a/docs/m5-upload-attestation-readiness.md b/docs/m5-upload-attestation-readiness.md index 94d7f2a..34b400f 100644 --- a/docs/m5-upload-attestation-readiness.md +++ b/docs/m5-upload-attestation-readiness.md @@ -141,8 +141,8 @@ from an old operation. All Xcode results and derived data are outside the repository under `/tmp`. The current local gate includes: -- `go test -tags noassets ./bridge -count=1`: passed, including the real - Syncthing ignore/access/collision preflight; +- `cd go && go test -tags noassets ./bridge -count=1`: passed, including the + real Syncthing ignore/access/collision preflight; - `go test ./... -count=1` in `notify/` on macOS: passed; - the same complete Notify suite in a digest-pinned, read-only Linux container with no network and all capabilities dropped: passed; diff --git a/ios/VaultSync/Services/DiagnosticsPairingController.swift b/ios/VaultSync/Services/DiagnosticsPairingController.swift index 592d2dc..cbc2e4a 100644 --- a/ios/VaultSync/Services/DiagnosticsPairingController.swift +++ b/ios/VaultSync/Services/DiagnosticsPairingController.swift @@ -611,11 +611,14 @@ final class DiagnosticsPairingController { } } catch let error as DiagnosticsProtocolError { guard uploadRunIDs[recordID] == runID else { return } - lastError = error + // After the request artifact exists, the generic error banners + // ("nothing was created or transferred") would be false; the + // phase from finishUploadFailure carries the truthful state. + lastError = artifactCreated ? nil : error finishUploadFailure(recordID: recordID, error: error, artifactCreated: artifactCreated) } catch { guard uploadRunIDs[recordID] == runID else { return } - lastError = .unavailable + lastError = artifactCreated ? nil : .unavailable finishUploadFailure(recordID: recordID, error: .unavailable, artifactCreated: artifactCreated) } } @@ -725,8 +728,13 @@ final class DiagnosticsPairingController { phase = .rateLimited case .unsupported: phase = .unsupported - case .conflict, .invalidMessage: + case .conflict: phase = .conflict + case .invalidMessage: + // Conflict is reserved for unexpected authenticated namespace + // content (D024); an invalid pinned-channel protocol message is + // an authenticated-protocol mismatch and terminates unsupported. + phase = .unsupported case .unavailable, .protectedDataUnavailable, .recoveryRequired: phase = artifactCreated ? .interrupted : .unavailable } diff --git a/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift b/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift index 8df6932..8aadf85 100644 --- a/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift +++ b/ios/VaultSync/Services/DiagnosticsUploadFileStore.swift @@ -47,14 +47,29 @@ enum DiagnosticsUploadFileStore { ) } guard file >= 0 else { throw mappedError(errno) } + // Capture the created entry's identity: between creation and a + // failure unlink, Syncthing or another process can rename this file + // and reuse the pathname — unlinking by name alone could then delete + // an entry this invocation never created. + var createdIdentity = stat() + let identityCaptured = fstat(file, &createdIdentity) == 0 var committed = false defer { close(file) - if !committed { - _ = filename.withCString { unlinkat(parent, $0, 0) } - _ = fsync(parent) + if !committed, identityCaptured { + var current = stat() + let unchanged = filename.withCString { name in + fstatat(parent, name, ¤t, AT_SYMLINK_NOFOLLOW) == 0 + && current.st_dev == createdIdentity.st_dev + && current.st_ino == createdIdentity.st_ino + } + if unchanged { + _ = filename.withCString { unlinkat(parent, $0, 0) } + _ = fsync(parent) + } } } + guard identityCaptured else { throw DiagnosticsProtocolError.unsupported } try data.withUnsafeBytes { buffer in guard let base = buffer.baseAddress else { diff --git a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift index fc1d0b0..19bc664 100644 --- a/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift +++ b/ios/VaultSyncTests/DiagnosticsUploadM5Tests.swift @@ -358,6 +358,7 @@ struct DiagnosticsUploadM5Tests { "DiagnosticsUploadProtocol.swift", "DiagnosticsUploadPreflight.swift", "DiagnosticsUploadFileStore.swift", + "DiagnosticsPairingController.swift", ].contains(url.lastPathComponent) { for forbiddenSink in [ "UserDefaults", "Keychain", "StoreKit", "APNs", "Cloud Relay", @@ -376,7 +377,9 @@ struct DiagnosticsUploadM5Tests { ) #expect(controlledView.contains("@Environment(\\.scenePhase)")) #expect(controlledView.contains("if phase != .active")) - #expect(controlledView.components(separatedBy: "cancelAllForegroundUploads()").count >= 3) + let cancellationHooks = + controlledView.components(separatedBy: "cancelAllForegroundUploads()").count - 1 + #expect(cancellationHooks >= 2) let allowedSnapshot = "phase=completed upload=true download=false roundtrip=false cleanup=0" for forbidden in [