Преглед изворни кода

Add reset button, invalidInput self-heal, and diagnostic logging for app attest

Deniz Cengiz пре 1 дан
родитељ
комит
f4da58c2ac

+ 12 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -68453,6 +68453,9 @@
         }
       }
     },
+    "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck." : {
+
+    },
     "Click to Snooze Alerts" : {
       "localizations" : {
         "bg" : {
@@ -199852,6 +199855,15 @@
         }
       }
     },
+    "Reset and retry send" : {
+
+    },
+    "Reset App Attest state" : {
+
+    },
+    "Reset App Attest state?" : {
+
+    },
     "Reset to Defaults" : {
       "localizations" : {
         "bg" : {

+ 31 - 0
Trio/Sources/Modules/Telemetry/View/TelemetryPreviewView.swift

@@ -4,6 +4,8 @@ import SwiftUI
 /// Linked to from Settings → App Diagnostics and from the migration sheet.
 struct TelemetryPreviewView: View {
     @State private var jsonText: String = ""
+    @State private var showResetConfirm: Bool = false
+    @State private var resetStatus: String?
 
     var body: some View {
         ScrollView {
@@ -29,12 +31,41 @@ struct TelemetryPreviewView: View {
                     Label("Copy JSON", systemImage: "doc.on.doc")
                 }
                 .buttonStyle(.bordered)
+
+                Button(role: .destructive) {
+                    showResetConfirm = true
+                } label: {
+                    Label("Reset App Attest state", systemImage: "arrow.counterclockwise.circle")
+                }
+                .buttonStyle(.bordered)
+
+                if let resetStatus {
+                    Text(resetStatus)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
             }
             .padding()
         }
         .navigationTitle("What's sent")
         .navigationBarTitleDisplayMode(.inline)
         .onAppear { jsonText = Self.renderPayload() }
+        .confirmationDialog(
+            "Reset App Attest state?",
+            isPresented: $showResetConfirm,
+            titleVisibility: .visible
+        ) {
+            Button("Reset and retry send", role: .destructive) {
+                TelemetryAttestor.shared.resetAttestState()
+                resetStatus = "Reset done — attempting a fresh send. Check logs for status."
+                Task { await TelemetryClient.shared.maybeSend() }
+            }
+            Button("Cancel", role: .cancel) {}
+        } message: {
+            Text(
+                "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck."
+            )
+        }
     }
 
     private static func renderPayload() -> String {

+ 34 - 2
Trio/Sources/Services/Telemetry/TelemetryAttestor.swift

@@ -81,6 +81,17 @@ final class TelemetryAttestor: Injectable {
         let challengeBytes = Data(challenge.utf8)
         let clientDataHash = Data(SHA256.hash(data: challengeBytes))
 
+        // Diagnostics for `attestKey` failures. We log shape, not values:
+        // keyID prefix only (the keyID is per-install and shouldn't end up in
+        // shareable logs in full). If any of these look off, the failure is
+        // ours; if they look right and Apple still rejects, the failure is
+        // server-side at Apple.
+        let keyIDPrefix = String(keyID.prefix(8))
+        debug(
+            .telemetry,
+            "attestKey input: isSupported=\(service.isSupported) keyID.count=\(keyID.count) keyID.prefix=\(keyIDPrefix) hash.count=\(clientDataHash.count) challenge.count=\(challenge.count) bundle=\(Bundle.main.bundleIdentifier ?? "nil")"
+        )
+
         let attestationCBOR: Data
         do {
             attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
@@ -89,14 +100,22 @@ final class TelemetryAttestor: Injectable {
             // Branch on the DCError code so logs distinguish the recoverable
             // cases from real failures:
             //   .invalidKey         — keyID is permanently burnt; drop it.
+            //   .invalidInput       — Apple rejected an argument as malformed.
+            //                         In practice we see this when the keyID
+            //                         is stale (e.g. survived an uninstall via
+            //                         Keychain) and no longer matches Apple's
+            //                         expected identity for this install. Drop
+            //                         the keyID — same recovery as invalidKey.
             //   .serverUnavailable  — Apple's App Attest backend is down or
             //                         throttling. Key is still valid; the
             //                         next cycle retries with the same keyID.
             if let dcError = error as? DCError {
                 switch dcError.code {
-                case .invalidKey:
+                case .invalidInput,
+                     .invalidKey:
                     keychain.removeObject(forKey: Self.keyIDStorageKey)
-                    debug(.telemetry, "attestKey invalidKey: discarded dead keyID; will regenerate next cycle")
+                    let reason = dcError.code == .invalidKey ? "invalidKey" : "invalidInput"
+                    debug(.telemetry, "attestKey \(reason): discarded keyID; will regenerate next cycle")
                 case .serverUnavailable:
                     debug(.telemetry, "attestKey serverUnavailable: Apple App Attest backend transient — will retry next cycle")
                 default:
@@ -160,6 +179,19 @@ final class TelemetryAttestor: Injectable {
         keychain.removeObject(forKey: Self.registeredStorageKey)
     }
 
+    /// Full local-state reset for stuck installs. In addition to what
+    /// `invalidateRegistration` clears, this also drops the sticky
+    /// `telemetryAttestForbidden` flag — so a tester who got 403'd and wants
+    /// to retry can do so without reinstalling. Exposed through a button in
+    /// the telemetry inspector. Does not touch consent or installId.
+    func resetAttestState() {
+        injectIfNeeded()
+        keychain.removeObject(forKey: Self.keyIDStorageKey)
+        keychain.removeObject(forKey: Self.registeredStorageKey)
+        PropertyPersistentFlags.shared.telemetryAttestForbidden = false
+        debug(.telemetry, "reset App Attest state: keyID, registered flag, and forbidden flag cleared")
+    }
+
     // MARK: - Per-ping assertion
 
     /// Builds the App Attest assertion for a single `/checkin` send.