Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .ai/mcp/mcp.json
Empty file.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Carthage/Checkouts/
.idea/
*.swp
*.swo
GoogleService-Info.plist
34 changes: 32 additions & 2 deletions ExampleApp/ExampleApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -40,6 +42,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
52BD9D732F34ADF700B8D3FD /* FirebaseAnalytics in Frameworks */,
52BD9D752F34ADF700B8D3FD /* FirebaseAppCheck in Frameworks */,
52D1F9FB2F25916400810D03 /* SignedShotSDK in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -84,6 +88,8 @@
name = ExampleApp;
packageProductDependencies = (
52D1F9FA2F25916400810D03 /* SignedShotSDK */,
52BD9D722F34ADF700B8D3FD /* FirebaseAnalytics */,
52BD9D742F34ADF700B8D3FD /* FirebaseAppCheck */,
);
productName = ExampleApp;
productReference = 52D1F9EB2F258D7600810D03 /* ExampleApp.app */;
Expand Down Expand Up @@ -115,6 +121,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
52D1F9F92F25916400810D03 /* XCLocalSwiftPackageReference "../../signedshot-ios" */,
52BD9D712F34ADF700B8D3FD /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 52D1F9EC2F258D7600810D03 /* Products */;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<key>ExampleApp.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
</dict>
</dict>
Expand Down
46 changes: 43 additions & 3 deletions ExampleApp/ExampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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)")
}
}

Expand All @@ -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
Expand All @@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions ExampleApp/ExampleApp/ExampleApp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>development</string>
</dict>
</plist>
44 changes: 39 additions & 5 deletions ExampleApp/ExampleApp/ExampleAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
4 changes: 4 additions & 0 deletions ExampleApp/ExampleApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>INIntentsSupported</key>
<array>
<string>Intent</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
Expand Down
23 changes: 19 additions & 4 deletions Sources/SignedShotSDK/SignedShotClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down