diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json
new file mode 100644
index 0000000..e69de29
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 92aa553..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 */;
@@ -273,6 +280,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 +299,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 +312,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 +331,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;
@@ -360,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.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/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/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/ExampleAppApp.swift b/ExampleApp/ExampleApp/ExampleAppApp.swift
index 94388b0..3089b8e 100644
--- a/ExampleApp/ExampleApp/ExampleAppApp.swift
+++ b/ExampleApp/ExampleApp/ExampleAppApp.swift
@@ -6,12 +6,46 @@
//
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)? {
+ // 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
+ }
+}
@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()
+ }
+ }
}
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
diff --git a/Sources/SignedShotSDK/SignedShotClient.swift b/Sources/SignedShotSDK/SignedShotClient.swift
index a7e78d6..3472ab9 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,11 @@ 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 +131,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 +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, isRetry: true)
+ return try await performRegistration(
+ externalId: newExternalId,
+ attestationToken: attestationToken,
+ isRetry: true
+ )
default:
let errorMessage = try? decoder.decode(APIErrorResponse.self, from: data).detail