From bab9d68df4a263fd03ab262f12d0d21eff9ac945 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Thu, 5 Feb 2026 07:44:05 -0300 Subject: [PATCH 1/4] feat: update Bundle ID and add App Attest capability - Change Bundle ID from io.signedshot.sdk.ExampleApp to io.signedshot.capture - Add App Attest capability for Firebase App Check support --- ExampleApp/ExampleApp.xcodeproj/project.pbxproj | 6 ++++-- .../xcschemes/xcschememanagement.plist | 2 +- ExampleApp/ExampleApp/ExampleApp.entitlements | 8 ++++++++ ExampleApp/ExampleApp/Info.plist | 4 ++++ 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 ExampleApp/ExampleApp/ExampleApp.entitlements diff --git a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj index 92aa553..968753b 100644 --- a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj +++ b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj @@ -273,6 +273,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ExampleApp/ExampleApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CMQ8K8AVJB; @@ -291,7 +292,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.signedshot.sdk.ExampleApp; + PRODUCT_BUNDLE_IDENTIFIER = io.signedshot.capture; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -304,6 +305,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ExampleApp/ExampleApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = CMQ8K8AVJB; @@ -322,7 +324,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = io.signedshot.sdk.ExampleApp; + PRODUCT_BUNDLE_IDENTIFIER = io.signedshot.capture; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/ExampleApp/ExampleApp.xcodeproj/xcuserdata/felippecosta.xcuserdatad/xcschemes/xcschememanagement.plist b/ExampleApp/ExampleApp.xcodeproj/xcuserdata/felippecosta.xcuserdatad/xcschemes/xcschememanagement.plist index 7d8ef2a..9d56fd5 100644 --- a/ExampleApp/ExampleApp.xcodeproj/xcuserdata/felippecosta.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ExampleApp/ExampleApp.xcodeproj/xcuserdata/felippecosta.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ ExampleApp.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/ExampleApp/ExampleApp/ExampleApp.entitlements b/ExampleApp/ExampleApp/ExampleApp.entitlements new file mode 100644 index 0000000..70084b4 --- /dev/null +++ b/ExampleApp/ExampleApp/ExampleApp.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.devicecheck.appattest-environment + development + + diff --git a/ExampleApp/ExampleApp/Info.plist b/ExampleApp/ExampleApp/Info.plist index ff579a6..1cee3bf 100644 --- a/ExampleApp/ExampleApp/Info.plist +++ b/ExampleApp/ExampleApp/Info.plist @@ -2,6 +2,10 @@ + INIntentsSupported + + Intent + UIFileSharingEnabled From a6d97a4a45611c9eafe179ef8a6ab7dd16be1bca Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Thu, 5 Feb 2026 08:11:25 -0300 Subject: [PATCH 2/4] feat: add Firebase App Check with App Attest - Add Firebase SDK dependency (FirebaseAppCheck) - Initialize Firebase and App Check on app launch - Use AppAttestProvider for real devices - Use AppCheckDebugProvider for simulator - Add GoogleService-Info.plist to gitignore --- .gitignore | 1 + .../ExampleApp.xcodeproj/project.pbxproj | 28 ++++++++++++ ExampleApp/ExampleApp/ExampleAppApp.swift | 43 ++++++++++++++++--- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 96133d9..ed9d892 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ Carthage/Checkouts/ .idea/ *.swp *.swo +GoogleService-Info.plist diff --git a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj index 968753b..353494b 100644 --- a/ExampleApp/ExampleApp.xcodeproj/project.pbxproj +++ b/ExampleApp/ExampleApp.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 52BD9D732F34ADF700B8D3FD /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 52BD9D722F34ADF700B8D3FD /* FirebaseAnalytics */; }; + 52BD9D752F34ADF700B8D3FD /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 52BD9D742F34ADF700B8D3FD /* FirebaseAppCheck */; }; 52D1F9FB2F25916400810D03 /* SignedShotSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 52D1F9FA2F25916400810D03 /* SignedShotSDK */; }; /* End PBXBuildFile section */ @@ -40,6 +42,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 52BD9D732F34ADF700B8D3FD /* FirebaseAnalytics in Frameworks */, + 52BD9D752F34ADF700B8D3FD /* FirebaseAppCheck in Frameworks */, 52D1F9FB2F25916400810D03 /* SignedShotSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -84,6 +88,8 @@ name = ExampleApp; packageProductDependencies = ( 52D1F9FA2F25916400810D03 /* SignedShotSDK */, + 52BD9D722F34ADF700B8D3FD /* FirebaseAnalytics */, + 52BD9D742F34ADF700B8D3FD /* FirebaseAppCheck */, ); productName = ExampleApp; productReference = 52D1F9EB2F258D7600810D03 /* ExampleApp.app */; @@ -115,6 +121,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 52D1F9F92F25916400810D03 /* XCLocalSwiftPackageReference "../../signedshot-ios" */, + 52BD9D712F34ADF700B8D3FD /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 52D1F9EC2F258D7600810D03 /* Products */; @@ -362,7 +369,28 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + 52BD9D712F34ADF700B8D3FD /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.9.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 52BD9D722F34ADF700B8D3FD /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 52BD9D712F34ADF700B8D3FD /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 52BD9D742F34ADF700B8D3FD /* FirebaseAppCheck */ = { + isa = XCSwiftPackageProductDependency; + package = 52BD9D712F34ADF700B8D3FD /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAppCheck; + }; 52D1F9FA2F25916400810D03 /* SignedShotSDK */ = { isa = XCSwiftPackageProductDependency; productName = SignedShotSDK; diff --git a/ExampleApp/ExampleApp/ExampleAppApp.swift b/ExampleApp/ExampleApp/ExampleAppApp.swift index 94388b0..f627de3 100644 --- a/ExampleApp/ExampleApp/ExampleAppApp.swift +++ b/ExampleApp/ExampleApp/ExampleAppApp.swift @@ -6,12 +6,45 @@ // import SwiftUI +import FirebaseCore +import FirebaseAppCheck + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + + // Set up App Check with App Attest provider (for real devices) + let providerFactory = SignedShotAppCheckProviderFactory() + AppCheck.setAppCheckProviderFactory(providerFactory) + + // Initialize Firebase + FirebaseApp.configure() + + return true + } +} + +// App Check provider factory - renamed to avoid conflict with protocol +class SignedShotAppCheckProviderFactory: NSObject, AppCheckProviderFactory { + func createProvider(with app: FirebaseApp) -> (any AppCheckProvider)? { + #if targetEnvironment(simulator) + // Use debug provider for simulator + return AppCheckDebugProvider(app: app) + #else + // Use App Attest for real devices + return AppAttestProvider(app: app) + #endif + } +} @main struct ExampleAppApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } + // Register app delegate for Firebase setup + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + + var body: some Scene { + WindowGroup { + ContentView() + } + } } From 3fdf852485e510234b8dc6acaa514e9e7cf53566 Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Thu, 5 Feb 2026 16:36:08 -0300 Subject: [PATCH 3/4] feat: add attestation token support for device registration --- .ai/mcp/mcp.json | 0 ExampleApp/ExampleApp/ContentView.swift | 46 ++++++++++++++++++-- ExampleApp/ExampleApp/ExampleAppApp.swift | 13 +++--- Sources/SignedShotSDK/SignedShotClient.swift | 15 +++++-- 4 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 .ai/mcp/mcp.json diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/ExampleApp/ExampleApp/ContentView.swift b/ExampleApp/ExampleApp/ContentView.swift index c6d0c40..a612590 100644 --- a/ExampleApp/ExampleApp/ContentView.swift +++ b/ExampleApp/ExampleApp/ContentView.swift @@ -3,10 +3,14 @@ // ExampleApp // +import FirebaseAppCheck +import os.log import SignedShotSDK import SwiftUI import UIKit +private let logger = Logger(subsystem: "io.signedshot.capture", category: "App") + struct ContentView: View { @StateObject private var captureService = CaptureService() @State private var lastCapturedPhoto: CapturedPhoto? @@ -171,7 +175,7 @@ struct ContentView: View { Spacer() - // Registration status indicator + // Registration status indicator (long press to reset) if isDeviceRegistered { HStack(spacing: 4) { Image(systemName: "checkmark.shield.fill") @@ -182,6 +186,13 @@ struct ContentView: View { .foregroundColor(.gray) } } + .contextMenu { + Button(role: .destructive) { + Task { await resetDevice() } + } label: { + Label("Reset Device", systemImage: "trash") + } + } } else { Image(systemName: "shield.slash") .foregroundColor(.orange) @@ -427,7 +438,7 @@ struct ContentView: View { } catch { // Secure Enclave not available (simulator) - continue without it isEnclaveReady = false - print("Secure Enclave not available: \(error.localizedDescription)") + logger.warning("Secure Enclave not available: \(error.localizedDescription)") } } @@ -446,9 +457,12 @@ struct ContentView: View { defer { isRegistering = false } do { + // Get Firebase App Check token for attestation + let attestationToken = try await getAppCheckToken() + // Registration handles external_id internally // If 409 conflict occurs, it auto-retries with a new ID - let response = try await client.registerDevice() + let response = try await client.registerDevice(attestationToken: attestationToken) isDeviceRegistered = true deviceId = response.deviceId @@ -457,6 +471,32 @@ struct ContentView: View { } } + private func resetDevice() async { + do { + try await client.clearStoredCredentials() + isDeviceRegistered = false + deviceId = nil + currentSession = nil + trustToken = nil + logger.info("Device credentials cleared successfully") + } catch { + errorMessage = "Failed to reset: \(error.localizedDescription)" + logger.error("Failed to clear device credentials: \(error.localizedDescription)") + } + } + + /// Get Firebase App Check token for device attestation + private func getAppCheckToken() async throws -> String? { + do { + let token = try await AppCheck.appCheck().token(forcingRefresh: false) + return token.token + } catch { + // Log but don't fail - let the backend decide if token is required + logger.warning("App Check token retrieval failed: \(error.localizedDescription)") + return nil + } + } + private func startSession() async { isStartingSession = true defer { isStartingSession = false } diff --git a/ExampleApp/ExampleApp/ExampleAppApp.swift b/ExampleApp/ExampleApp/ExampleAppApp.swift index f627de3..3089b8e 100644 --- a/ExampleApp/ExampleApp/ExampleAppApp.swift +++ b/ExampleApp/ExampleApp/ExampleAppApp.swift @@ -27,13 +27,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { // App Check provider factory - renamed to avoid conflict with protocol class SignedShotAppCheckProviderFactory: NSObject, AppCheckProviderFactory { func createProvider(with app: FirebaseApp) -> (any AppCheckProvider)? { - #if targetEnvironment(simulator) - // Use debug provider for simulator + // TODO: Re-enable App Attest once configuration issue is resolved + // #if targetEnvironment(simulator) + // Use debug provider for now (both simulator and device) return AppCheckDebugProvider(app: app) - #else - // Use App Attest for real devices - return AppAttestProvider(app: app) - #endif + // #else + // // Use App Attest for real devices + // return AppAttestProvider(app: app) + // #endif } } diff --git a/Sources/SignedShotSDK/SignedShotClient.swift b/Sources/SignedShotSDK/SignedShotClient.swift index a7e78d6..49ddea9 100644 --- a/Sources/SignedShotSDK/SignedShotClient.swift +++ b/Sources/SignedShotSDK/SignedShotClient.swift @@ -85,12 +85,13 @@ public actor SignedShotClient { } /// Register this device with the SignedShot backend + /// - Parameter attestationToken: Optional attestation token (e.g., from Firebase App Check) /// - Returns: The registration response containing device info and token /// - Throws: SignedShotAPIError if registration fails @discardableResult - public func registerDevice() async throws -> DeviceCreateResponse { + public func registerDevice(attestationToken: String? = nil) async throws -> DeviceCreateResponse { let extId = try getOrCreateExternalId() - return try await performRegistration(externalId: extId, isRetry: false) + return try await performRegistration(externalId: extId, attestationToken: attestationToken, isRetry: false) } /// Clear stored device credentials (for re-registration) @@ -115,7 +116,7 @@ public actor SignedShotClient { return newId } - private func performRegistration(externalId: String, isRetry: Bool) async throws -> DeviceCreateResponse { + private func performRegistration(externalId: String, attestationToken: String? = nil, isRetry: Bool) async throws -> DeviceCreateResponse { SignedShotLogger.api.info("Registering device with externalId: \(externalId.prefix(8))...") let url = configuration.baseURL.appendingPathComponent("devices") @@ -126,6 +127,12 @@ public actor SignedShotClient { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(configuration.publisherId, forHTTPHeaderField: "X-Publisher-ID") + // Add attestation token header if provided + if let token = attestationToken { + request.setValue(token, forHTTPHeaderField: "X-Attestation-Token") + SignedShotLogger.api.debug("Including attestation token in request") + } + let body = DeviceCreateRequest(externalId: externalId) request.httpBody = try encoder.encode(body) @@ -167,7 +174,7 @@ public actor SignedShotClient { SignedShotLogger.api.warning("Device conflict - clearing credentials and retrying") try clearStoredCredentials() let newExternalId = try getOrCreateExternalId() - return try await performRegistration(externalId: newExternalId, isRetry: true) + return try await performRegistration(externalId: newExternalId, attestationToken: attestationToken, isRetry: true) default: let errorMessage = try? decoder.decode(APIErrorResponse.self, from: data).detail From 95c750229f78ceb2826f594c4b7ef5b91e27972d Mon Sep 17 00:00:00 2001 From: Felippe Costa Date: Thu, 5 Feb 2026 16:45:11 -0300 Subject: [PATCH 4/4] style: fix line length violations in SignedShotClient --- Sources/SignedShotSDK/SignedShotClient.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/SignedShotSDK/SignedShotClient.swift b/Sources/SignedShotSDK/SignedShotClient.swift index 49ddea9..3472ab9 100644 --- a/Sources/SignedShotSDK/SignedShotClient.swift +++ b/Sources/SignedShotSDK/SignedShotClient.swift @@ -116,7 +116,11 @@ public actor SignedShotClient { return newId } - private func performRegistration(externalId: String, attestationToken: String? = nil, isRetry: Bool) async throws -> DeviceCreateResponse { + private func performRegistration( + externalId: String, + attestationToken: String? = nil, + isRetry: Bool + ) async throws -> DeviceCreateResponse { SignedShotLogger.api.info("Registering device with externalId: \(externalId.prefix(8))...") let url = configuration.baseURL.appendingPathComponent("devices") @@ -174,7 +178,11 @@ public actor SignedShotClient { SignedShotLogger.api.warning("Device conflict - clearing credentials and retrying") try clearStoredCredentials() let newExternalId = try getOrCreateExternalId() - return try await performRegistration(externalId: newExternalId, attestationToken: attestationToken, isRetry: true) + return try await performRegistration( + externalId: newExternalId, + attestationToken: attestationToken, + isRetry: true + ) default: let errorMessage = try? decoder.decode(APIErrorResponse.self, from: data).detail