Skip to content
Open
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
89 changes: 89 additions & 0 deletions Sources/Cryptography/DeviceService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
121 changes: 121 additions & 0 deletions Sources/Cryptography/OnboardingService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading