Просмотр исходного кода

Add Apple App Attest auth to telemetry uploader

Replaces the placeholder bearer-token scheme with Apple App Attest as
the per-request authenticator
Deniz Cengiz недель назад: 2
Родитель
Сommit
f76d2ce21b

+ 9 - 0
PRIVACY_POLICY.md

@@ -45,6 +45,15 @@ app launch after the update. You can change your choice at any time
 in Settings → App Diagnostics, and you can inspect the exact JSON
 in Settings → App Diagnostics, and you can inspect the exact JSON
 that would be sent under "What's sent" on that same screen.
 that would be sent under "What's sent" on that same screen.
 
 
+Telemetry requests are authenticated with Apple App Attest. This
+means Apple cryptographically vouches for the fact that the request
+came from a genuine, unmodified copy of Trio running on a real
+Apple device. App Attest does not transmit any personal data,
+device identifiers, or location information; it produces a one-way
+attestation that the server validates with Apple. Devices that do
+not support App Attest (e.g. the iOS Simulator) silently skip
+sending telemetry.
+
 The diagnostics-sharing selection offers three options:
 The diagnostics-sharing selection offers three options:
 
 
 - **Enable Full Sharing** — crash reports AND anonymous usage telemetry.
 - **Enable Full Sharing** — crash reports AND anonymous usage telemetry.

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -620,6 +620,7 @@
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
 		DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */; };
 		DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000001 /* TelemetryClient.swift */; };
 		DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000001 /* TelemetryClient.swift */; };
+		DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000013 /* TelemetryAttestor.swift */; };
 		DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000003 /* TelemetryPreviewView.swift */; };
 		DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000003 /* TelemetryPreviewView.swift */; };
 		DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */; };
 		DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */; };
 		DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */; };
 		DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */; };
@@ -1484,6 +1485,7 @@
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD8262CA2D289297009F6F62 /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
 		DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyPersistentFlags.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000001 /* TelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryClient.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000001 /* TelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryClient.swift; sourceTree = "<group>"; };
+		DD7E1E300000000000000013 /* TelemetryAttestor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryAttestor.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000003 /* TelemetryPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPreviewView.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000003 /* TelemetryPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPreviewView.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPrivacyView.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000005 /* TelemetryPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryPrivacyView.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryMigrationSheetView.swift; sourceTree = "<group>"; };
 		DD7E1E300000000000000007 /* TelemetryMigrationSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryMigrationSheetView.swift; sourceTree = "<group>"; };
@@ -2143,6 +2145,7 @@
 		DD7E1E30000000000000000A /* Telemetry */ = {
 		DD7E1E30000000000000000A /* Telemetry */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				DD7E1E300000000000000013 /* TelemetryAttestor.swift */,
 				DD7E1E300000000000000001 /* TelemetryClient.swift */,
 				DD7E1E300000000000000001 /* TelemetryClient.swift */,
 			);
 			);
 			path = Telemetry;
 			path = Telemetry;
@@ -4413,6 +4416,7 @@
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
 				DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */,
 				DD7E1E300000000000000002 /* TelemetryClient.swift in Sources */,
+				DD7E1E300000000000000014 /* TelemetryAttestor.swift in Sources */,
 				DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */,
 				DD7E1E300000000000000004 /* TelemetryPreviewView.swift in Sources */,
 				DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */,
 				DD7E1E300000000000000006 /* TelemetryPrivacyView.swift in Sources */,
 				DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */,
 				DD7E1E300000000000000008 /* TelemetryMigrationSheetView.swift in Sources */,

+ 9 - 0
Trio/Sources/Helpers/PropertyPersistentFlags.swift

@@ -43,4 +43,13 @@ final class PropertyPersistentFlags {
     // Stable per-install UUID. IDFV resets when the user removes all Trio-team apps;
     // Stable per-install UUID. IDFV resets when the user removes all Trio-team apps;
     // this survives independently and is wiped only by deleting Trio itself.
     // this survives independently and is wiped only by deleting Trio itself.
     @PersistedProperty(key: "telemetryInstallId") var telemetryInstallId: String?
     @PersistedProperty(key: "telemetryInstallId") var telemetryInstallId: String?
+
+    // App Attest "give up" signal — set on a 403 from /api/attest/register, meaning
+    // the server has rejected this app_id and there's no point retrying.
+    @PersistedProperty(key: "telemetryAttestForbidden") var telemetryAttestForbidden: Bool?
+
+    // Debug override for the telemetry server base URL. Empty/unset → use the
+    // production constant in TelemetryClient. Surfaced as a hidden field in
+    // App Diagnostics for local testing against a dev server.
+    @PersistedProperty(key: "telemetryDebugServerURL") var telemetryDebugServerURL: String?
 }
 }

+ 3 - 1
Trio/Sources/Helpers/PropertyWrappers/PersistedProperty.swift

@@ -129,7 +129,9 @@ enum FileProtectionFixer {
             "telemetryLastSentAt.plist",
             "telemetryLastSentAt.plist",
             "telemetryLastSentSha.plist",
             "telemetryLastSentSha.plist",
             "telemetryColdLaunchTimes.plist",
             "telemetryColdLaunchTimes.plist",
-            "telemetryInstallId.plist"
+            "telemetryInstallId.plist",
+            "telemetryAttestForbidden.plist",
+            "telemetryDebugServerURL.plist"
         ]
         ]
 
 
         let fileManager = FileManager.default
         let fileManager = FileManager.default

+ 273 - 0
Trio/Sources/Services/Telemetry/TelemetryAttestor.swift

@@ -0,0 +1,273 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import Swinject
+
+// MARK: - TelemetryAttestor
+
+/// Apple App Attest wrapper for the telemetry uploader. Owns:
+///   - the per-install App Attest key (generated once, persisted in Keychain)
+///   - the "this install has been registered with the server" flag (Keychain)
+///   - challenge fetch + assertion generation per send cycle
+///
+/// Designed to fail soft: if the device doesn't support App Attest
+/// (simulators, older iOS, etc.), `isSupported` is false and the caller
+/// should silently skip the send. Server-side rejections (403 from the
+/// register endpoint) are sticky — recorded in PropertyPersistentFlags so
+/// subsequent cycles don't retry indefinitely.
+///
+/// Wire protocol matches `nightscout/trio-telemetry` (branch `app-attest`):
+///   1. POST /api/auth/ios/challenge       → { "challenge": "<base64url>" }
+///   2. POST /api/attest/register          (once per install)
+///   3. /checkin                           (per ping, headers below)
+final class TelemetryAttestor: Injectable {
+    static let shared = TelemetryAttestor()
+
+    @Injected() private var keychain: Keychain!
+
+    private let service = DCAppAttestService.shared
+    private let lock = NSRecursiveLock()
+    private var didInjectServices = false
+
+    private static let keyIDStorageKey = "TelemetryAttest.keyID"
+    private static let registeredStorageKey = "TelemetryAttest.registered"
+
+    private init() {}
+
+    private func injectIfNeeded() {
+        lock.lock()
+        defer { lock.unlock() }
+        guard !didInjectServices else { return }
+        injectServices(TrioApp.resolver)
+        didInjectServices = true
+    }
+
+    /// True when the running device supports App Attest. Returns false on the
+    /// simulator and on devices that lack a Secure Enclave.
+    var isSupported: Bool {
+        service.isSupported
+    }
+
+    /// True once a 403 from `/api/attest/register` has flagged this install
+    /// as permanently rejected — typically a misconfigured `app_id`. Callers
+    /// should stop attempting to send.
+    var isForbidden: Bool {
+        PropertyPersistentFlags.shared.telemetryAttestForbidden == true
+    }
+
+    // MARK: - Registration
+
+    /// Idempotent: returns immediately if already registered. Otherwise
+    /// performs `generateKey` → fetch challenge → `attestKey` → POST register.
+    /// Throws on transport / server errors; sets the sticky "forbidden" flag
+    /// on a 403 so future cycles short-circuit.
+    func registerIfNeeded(baseURL: URL) async throws {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        if (keychain.getValue(Bool.self, forKey: Self.registeredStorageKey) ?? false) == true {
+            return
+        }
+
+        // generateKey() returns a base64url-encoded key identifier (Apple's docs).
+        // We persist it as-is for use in the assertion path below.
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        // App Attest expects a SHA-256 of the "client data" — for the
+        // attestation step, that's the challenge bytes alone.
+        let challengeBytes = Data(challenge.utf8)
+        let clientDataHash = Data(SHA256.hash(data: challengeBytes))
+
+        let attestationCBOR: Data
+        do {
+            attestationCBOR = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+        } catch {
+            debug(.telemetry, "attestKey failed: \(error.localizedDescription)")
+            throw AttestError.attestationFailed(error)
+        }
+
+        guard let appID = Self.currentAppID() else {
+            throw AttestError.unknownAppID
+        }
+
+        let body: [String: Any] = [
+            "attestation": attestationCBOR.base64EncodedString(),
+            "key_id": keyID,
+            "challenge": challenge,
+            "app_id": appID
+        ]
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/attest/register"))
+        request.httpMethod = "POST"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+        request.timeoutInterval = 15
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+
+        switch http.statusCode {
+        case 200,
+             201:
+            keychain.setValue(true, forKey: Self.registeredStorageKey)
+            debug(.telemetry, "register ok status=\(http.statusCode)")
+        case 403:
+            // app_id rejected. Sticky — flag the install and surface to caller.
+            PropertyPersistentFlags.shared.telemetryAttestForbidden = true
+            debug(.telemetry, "register forbidden — app_id=\(appID) rejected; no further attempts")
+            throw AttestError.forbidden
+        case 400 ..< 500:
+            throw AttestError.clientError(http.statusCode)
+        case 500 ..< 600:
+            throw AttestError.serverError(http.statusCode)
+        default:
+            throw AttestError.serverError(http.statusCode)
+        }
+    }
+
+    // MARK: - Per-ping assertion
+
+    /// Builds the App Attest assertion for a single `/checkin` send.
+    ///
+    /// `clientDataHash` for the assertion is `SHA256(payloadBytes || challengeBytes)`.
+    /// **Order matters**: payload first, then the challenge (per the server
+    /// spec). Returns the base64-encoded assertion CBOR, the keyID (already a
+    /// base64url string), and the challenge string — all three become headers
+    /// on the outgoing request.
+    func assertion(forPayload payload: Data, baseURL: URL) async throws -> (assertion: String, keyID: String, challenge: String) {
+        injectIfNeeded()
+
+        guard isSupported else { throw AttestError.unsupportedDevice }
+        guard !isForbidden else { throw AttestError.forbidden }
+
+        let keyID = try await currentOrCreateKeyID()
+        let challenge = try await fetchChallenge(baseURL: baseURL)
+
+        var hasher = SHA256()
+        hasher.update(data: payload)
+        hasher.update(data: Data(challenge.utf8))
+        let clientDataHash = Data(hasher.finalize())
+
+        let assertionCBOR: Data
+        do {
+            assertionCBOR = try await service.generateAssertion(keyID, clientDataHash: clientDataHash)
+        } catch {
+            throw AttestError.assertionFailed(error)
+        }
+        return (assertionCBOR.base64EncodedString(), keyID, challenge)
+    }
+
+    // MARK: - Helpers
+
+    /// Reads the cached App Attest key identifier from Keychain, generating a
+    /// new one (and persisting it) on first call. The keyID is the only thing
+    /// we store — Apple holds the actual private key in the Secure Enclave.
+    private func currentOrCreateKeyID() async throws -> String {
+        if let cached = keychain.getValue(String.self, forKey: Self.keyIDStorageKey),
+           !cached.isEmpty
+        {
+            return cached
+        }
+        let newKey: String
+        do {
+            newKey = try await service.generateKey()
+        } catch {
+            throw AttestError.keyGenerationFailed(error)
+        }
+        keychain.setValue(newKey, forKey: Self.keyIDStorageKey)
+        debug(.telemetry, "generated new App Attest keyID")
+        return newKey
+    }
+
+    private func fetchChallenge(baseURL: URL) async throws -> String {
+        var request = URLRequest(url: baseURL.appendingPathComponent("api/auth/ios/challenge"))
+        request.httpMethod = "POST"
+        request.timeoutInterval = 15
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw AttestError.transportError
+        }
+        guard (200 ..< 300).contains(http.statusCode) else {
+            if (500 ..< 600).contains(http.statusCode) {
+                throw AttestError.serverError(http.statusCode)
+            }
+            throw AttestError.clientError(http.statusCode)
+        }
+
+        struct ChallengeResponse: Decodable { let challenge: String }
+        do {
+            let cr = try JSONDecoder().decode(ChallengeResponse.self, from: data)
+            return cr.challenge
+        } catch {
+            throw AttestError.malformedResponse
+        }
+    }
+
+    /// Produces the `<TEAMID>.<bundle-id>` string the server expects in
+    /// `app_id` — matches the regex `^[A-Z0-9]+\.org\.nightscout\.[^.]+\.trio$`
+    /// when the build is configured correctly.
+    ///
+    /// Reads `application-identifier` from `embedded.mobileprovision`. On iOS
+    /// the SDK doesn't expose `SecTaskCopyValueForEntitlement` to Swift, and
+    /// parsing the mobile-provision file is the standard workaround. Returns
+    /// nil for App Store builds (no embedded.mobileprovision) — which Trio
+    /// doesn't ship, so this path is fine for sideload + TestFlight.
+    static func currentAppID() -> String? {
+        guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
+              let raw = try? Data(contentsOf: url)
+        else { return nil }
+
+        // The mobileprovision file is a CMS-signed envelope around a plist.
+        // Pull the plist substring between the XML prolog and `</plist>`.
+        guard let ascii = String(data: raw, encoding: .ascii),
+              let start = ascii.range(of: "<?xml"),
+              let end = ascii.range(of: "</plist>")
+        else { return nil }
+
+        let plistString = String(ascii[start.lowerBound ..< end.upperBound])
+        guard let plistData = plistString.data(using: .utf8),
+              let plist = try? PropertyListSerialization
+              .propertyList(from: plistData, options: [], format: nil) as? [String: Any],
+              let entitlements = plist["Entitlements"] as? [String: Any],
+              let appID = entitlements["application-identifier"] as? String
+        else { return nil }
+
+        return appID
+    }
+
+    // MARK: - Errors
+
+    enum AttestError: Error, CustomStringConvertible {
+        case unsupportedDevice
+        case forbidden
+        case unknownAppID
+        case keyGenerationFailed(Error)
+        case attestationFailed(Error)
+        case assertionFailed(Error)
+        case transportError
+        case malformedResponse
+        case clientError(Int)
+        case serverError(Int)
+
+        var description: String {
+            switch self {
+            case .unsupportedDevice: return "App Attest unsupported on this device"
+            case .forbidden: return "app_id forbidden by server"
+            case .unknownAppID: return "unable to read application-identifier entitlement"
+            case let .keyGenerationFailed(e): return "generateKey failed: \(e.localizedDescription)"
+            case let .attestationFailed(e): return "attestKey failed: \(e.localizedDescription)"
+            case let .assertionFailed(e): return "generateAssertion failed: \(e.localizedDescription)"
+            case .transportError: return "non-HTTP response"
+            case .malformedResponse: return "malformed challenge response"
+            case let .clientError(code): return "client error \(code)"
+            case let .serverError(code): return "server error \(code)"
+            }
+        }
+    }
+}

+ 76 - 15
Trio/Sources/Services/Telemetry/TelemetryClient.swift

@@ -18,16 +18,29 @@ final class TelemetryClient: Injectable {
 
 
     // MARK: Endpoint configuration
     // MARK: Endpoint configuration
 
 
-    // TODO: Replace with the production telemetry endpoint
-    // and bearer token. While these placeholders remain, `send()` no-ops at
-    // debug-log level — consent, persistence, scheduling, and the UI work
-    // unchanged for testing against any mock server.
-    private static let endpoint: URL? = nil
-    private static let writeToken = ""
+    // TODO: Replace with the production `trio-telemetry` base URL once the
+    // server PR (nightscout/trio-telemetry#3) is deployed. Auth happens via
+    // Apple App Attest — see `TelemetryAttestor` — so there is no static
+    // bearer token. While this constant is nil, `send()` no-ops cleanly.
+    private static let productionBaseURL: URL? = nil
+
+    /// Effective base URL: respects the debug override in
+    /// `PropertyPersistentFlags.telemetryDebugServerURL`, then falls back to
+    /// `productionBaseURL`. Used by both the registration and `/checkin` paths.
+    private static var baseURL: URL? {
+        if let override = PropertyPersistentFlags.shared.telemetryDebugServerURL?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+            !override.isEmpty,
+            let url = URL(string: override)
+        {
+            return url
+        }
+        return productionBaseURL
+    }
 
 
     private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
     private static let weeklyInterval: TimeInterval = 7 * 24 * 60 * 60
     private static let dailyInterval: TimeInterval = 24 * 60 * 60
     private static let dailyInterval: TimeInterval = 24 * 60 * 60
-    private static let retryAfterFailureInterval: TimeInterval = 60
+    private static let maxPayloadBytes = 4096
 
 
     // MARK: Injected services
     // MARK: Injected services
 
 
@@ -213,25 +226,73 @@ final class TelemetryClient: Injectable {
 
 
     // MARK: - Send
     // MARK: - Send
 
 
-    /// Build payload, POST it, update last-sent state on 2xx. Fire-and-forget;
-    /// errors are logged at debug level only and never surfaced to the UI.
+    /// Build payload, attest it via App Attest, POST it, update last-sent state
+    /// on 2xx. Fire-and-forget; errors are logged at debug level only.
+    ///
+    /// Flow:
+    /// 1. Skip if `TelemetryAttestor.isSupported == false` (simulator, older
+    ///    devices). This is the primary opt-out for unsupported hardware —
+    ///    sending without attestation would just bounce off the server.
+    /// 2. Skip if the install has been flagged forbidden by a previous 403.
+    /// 3. Register if needed (idempotent; first launch + once on retry after
+    ///    transient failures).
+    /// 4. Serialize the payload. Reject if > 4096 bytes (server-enforced cap).
+    /// 5. Ask the attestor for an assertion over `SHA256(payload || challenge)`.
+    /// 6. POST `/checkin` with the three App Attest headers.
+    ///
+    /// Backoff: failures don't update `telemetryLastSentAt`, so the next
+    /// scheduler tick / cold launch retries naturally. The 24h cadence is the
+    /// natural backoff floor; no per-attempt exponential timer is added.
     func send() async {
     func send() async {
-        guard let endpoint = Self.endpoint else {
-            debug(.telemetry, "skip send: endpoint not configured (TODO)") // FIXME: adjust debug statement once backend is set up
+        guard let baseURL = Self.baseURL else {
+            debug(.telemetry, "skip send: server URL not configured")
+            return
+        }
+
+        let attestor = TelemetryAttestor.shared
+        guard attestor.isSupported else {
+            debug(.telemetry, "skip send: App Attest unsupported (simulator or older device)")
+            return
+        }
+        guard !attestor.isForbidden else {
+            debug(.telemetry, "skip send: app_id previously rejected (403)")
+            return
+        }
+
+        do {
+            try await attestor.registerIfNeeded(baseURL: baseURL)
+        } catch TelemetryAttestor.AttestError.forbidden {
+            // Already logged + sticky-flagged in registerIfNeeded.
+            return
+        } catch {
+            debug(.telemetry, "register failed: \(error) — will retry next cycle")
             return
             return
         }
         }
+
         let payload = buildPayload()
         let payload = buildPayload()
         guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
         guard let body = try? JSONSerialization.data(withJSONObject: payload, options: []) else {
             debug(.telemetry, "skip send: payload not JSON-serializable")
             debug(.telemetry, "skip send: payload not JSON-serializable")
             return
             return
         }
         }
+        guard body.count <= Self.maxPayloadBytes else {
+            debug(.telemetry, "skip send: payload exceeds \(Self.maxPayloadBytes) bytes (\(body.count))")
+            return
+        }
 
 
-        var request = URLRequest(url: endpoint)
+        let assertion: (assertion: String, keyID: String, challenge: String)
+        do {
+            assertion = try await attestor.assertion(forPayload: body, baseURL: baseURL)
+        } catch {
+            debug(.telemetry, "assertion failed: \(error)")
+            return
+        }
+
+        var request = URLRequest(url: baseURL.appendingPathComponent("checkin"))
         request.httpMethod = "POST"
         request.httpMethod = "POST"
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        if !Self.writeToken.isEmpty {
-            request.setValue("Bearer \(Self.writeToken)", forHTTPHeaderField: "Authorization")
-        }
+        request.setValue(assertion.keyID, forHTTPHeaderField: "X-AppAttest-KeyId")
+        request.setValue(assertion.assertion, forHTTPHeaderField: "X-AppAttest-Assertion")
+        request.setValue(assertion.challenge, forHTTPHeaderField: "X-Challenge")
         request.httpBody = body
         request.httpBody = body
         request.timeoutInterval = 15
         request.timeoutInterval = 15