diff --git a/Sources/Cryptography/DeviceService.swift b/Sources/Cryptography/DeviceService.swift new file mode 100644 index 0000000..14d7062 --- /dev/null +++ b/Sources/Cryptography/DeviceService.swift @@ -0,0 +1,89 @@ +import CryptoKit +import Foundation + +public struct AuthData: Codable, Sendable { + public let jwt: String + public let apiKey: String + + public init(jwt: String, apiKey: String) { + self.jwt = jwt + self.apiKey = apiKey + } +} + +public enum DeviceServiceError: Error, Equatable { + case keyGenerationFailed + case keyStorageFailed + case keyRetrievalFailed + case invalidKeyData + case hashingFailed + + public var errorMessage: String { + switch self { + case .keyGenerationFailed: + return "Failed to generate identity keys" + case .keyStorageFailed: + return "Failed to store identity keys" + case .keyRetrievalFailed: + return "Failed to retrieve identity keys" + case .invalidKeyData: + return "Invalid key data" + case .hashingFailed: + return "Failed to hash public key" + } + } +} + +public actor DeviceService { + private static let identityStorageKey = "crossmint-identity-key" + private let userDefaults: UserDefaults + + public init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + public func getId() async throws -> String { + let publicKey = try await getIdentityPublicKey() + let rawKeyData = publicKey.rawRepresentation + let hash = SHA256.hash(data: rawKeyData) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + public func getSerializedIdentityPublicKey() async throws -> String { + let publicKey = try await getIdentityPublicKey() + let rawKeyData = publicKey.rawRepresentation + return rawKeyData.base64EncodedString() + } + + public func getIdentityPublicKey() async throws -> P256.KeyAgreement.PublicKey { + let keyPair = try await getOrCreateIdentityKeys() + return keyPair.publicKey + } + + private func getOrCreateIdentityKeys() async throws -> P256.KeyAgreement.PrivateKey { + if let existingKey = loadStoredKey() { + return existingKey + } + + let newKey = P256.KeyAgreement.PrivateKey() + try storeKey(newKey) + return newKey + } + + private func loadStoredKey() -> P256.KeyAgreement.PrivateKey? { + guard let keyData = userDefaults.data(forKey: Self.identityStorageKey) else { + return nil + } + + return try? P256.KeyAgreement.PrivateKey(rawRepresentation: keyData) + } + + private func storeKey(_ key: P256.KeyAgreement.PrivateKey) throws { + let keyData = key.rawRepresentation + userDefaults.set(keyData, forKey: Self.identityStorageKey) + } + + public func clearIdentityKeys() { + userDefaults.removeObject(forKey: Self.identityStorageKey) + } +} diff --git a/Sources/Cryptography/OnboardingService.swift b/Sources/Cryptography/OnboardingService.swift new file mode 100644 index 0000000..89873ad --- /dev/null +++ b/Sources/Cryptography/OnboardingService.swift @@ -0,0 +1,121 @@ +import CrossmintService +import Foundation +import Http + +public struct StartOnboardingInput: Codable, Sendable { + public let authId: String + public let deviceId: String + public let encryptionContext: EncryptionContext + + public init(authId: String, deviceId: String, encryptionContext: EncryptionContext) { + self.authId = authId + self.deviceId = deviceId + self.encryptionContext = encryptionContext + } +} + +public struct EncryptionContext: Codable, Sendable { + public let publicKey: String + + public init(publicKey: String) { + self.publicKey = publicKey + } +} + +public enum OnboardingEndpoint { + case startOnboarding(input: StartOnboardingInput, authData: AuthData) + + var endpoint: Endpoint { + switch self { + case .startOnboarding(let input, let authData): + let body = try? JSONEncoder().encode(input) + return Endpoint( + path: "/ncs/v1/signers/start-onboarding", + method: .post, + headers: [ + "Content-Type": "application/json", + "Authorization": "Bearer \(authData.jwt)", + "x-api-key": authData.apiKey + ], + body: body + ) + } + } +} + +public enum OnboardingError: Error, Equatable, ServiceError { + case startOnboardingFailed(String) + case deviceNotReady + case invalidAuthData + + public static func fromServiceError(_ error: CrossmintServiceError) -> OnboardingError { + .startOnboardingFailed(error.errorMessage) + } + + public static func fromNetworkError(_ error: NetworkError) -> OnboardingError { + let message = error.serviceErrorMessage ?? error.localizedDescription + return .startOnboardingFailed(message) + } + + public var errorMessage: String { + switch self { + case .startOnboardingFailed(let message): + return "Failed to start onboarding: \(message)" + case .deviceNotReady: + return "Device is not ready for onboarding" + case .invalidAuthData: + return "Invalid authentication data" + } + } +} + +public enum SignerStatus: String, Sendable { + case ready + case newDevice = "new-device" +} + +public struct StartOnboardingResult: Sendable { + public let signerStatus: SignerStatus + + public init(signerStatus: SignerStatus) { + self.signerStatus = signerStatus + } +} + +public actor OnboardingService { + private let service: CrossmintService + private let deviceService: DeviceService + + public init(service: CrossmintService, deviceService: DeviceService) { + self.service = service + self.deviceService = deviceService + } + + public func startOnboarding( + authId: String, + authData: AuthData + ) async throws -> StartOnboardingResult { + let deviceId = try await deviceService.getId() + let publicKey = try await deviceService.getSerializedIdentityPublicKey() + + let input = StartOnboardingInput( + authId: authId, + deviceId: deviceId, + encryptionContext: EncryptionContext(publicKey: publicKey) + ) + + let endpoint = OnboardingEndpoint.startOnboarding(input: input, authData: authData) + + do { + try await service.executeRequest( + endpoint.endpoint, + errorType: OnboardingError.self + ) + return StartOnboardingResult(signerStatus: .newDevice) + } catch let error as OnboardingError { + throw error + } catch { + throw OnboardingError.startOnboardingFailed(error.localizedDescription) + } + } +}