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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ public final class HubAuthenticationViewModel: ObservableObject {
static var subscriptionState: String {
"hub-subscription-state"
}

static var iosLicense: String {
"hub-ios-license"
}
}

@Published var authenticationFlowState: State?
Expand All @@ -55,6 +59,7 @@ public final class HubAuthenticationViewModel: ObservableObject {
@Dependency(\.hubDeviceRegisteringService) var deviceRegisteringService
@Dependency(\.hubKeyService) var hubKeyService
@Dependency(\.cryptomatorHubKeyProvider) var cryptomatorHubKeyProvider
@Dependency(\.hubLicenseVerifier) var hubLicenseVerifier
private weak var delegate: HubAuthenticationViewModelDelegate?

public init(authState: OIDAuthState,
Expand Down Expand Up @@ -120,7 +125,7 @@ public final class HubAuthenticationViewModel: ObservableObject {
do {
let deviceKey = try cryptomatorHubKeyProvider.getPrivateKey()
userKey = try JWEHelper.decryptUserKey(jwe: flowResponse.encryptedUserKey, privateKey: deviceKey)
subscriptionState = getSubscriptionState(from: flowResponse.header)
subscriptionState = try resolveSubscriptionState(from: flowResponse.header)
} catch {
await setStateToErrorState(with: error)
return
Expand All @@ -143,6 +148,19 @@ public final class HubAuthenticationViewModel: ObservableObject {
await setState(to: .error(description: error.localizedDescription))
}

private func resolveSubscriptionState(from header: [AnyHashable: Any]) throws -> HubSubscriptionState {
guard let licenseToken = header[Constants.iosLicense] as? String else {
return getSubscriptionState(from: header)
}
switch try hubLicenseVerifier.verify(token: licenseToken) {
case .valid:
return .active
case .expired:
DDLogInfo("Hub-iOS-License expired, defaulting to inactive")
return .inactive
}
}

private func getSubscriptionState(from header: [AnyHashable: Any]) -> HubSubscriptionState {
guard let subscriptionStateValue = header[Constants.subscriptionState] as? String else {
DDLogInfo("Hub-Subscription-State header missing, defaulting to inactive")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// HubLicenseVerifier.swift
// CryptomatorCommonCore
//
// Created by Tobias Hagemann on 09.06.26.
// Copyright © 2026 Skymatic GmbH. All rights reserved.
//

import CryptoKit
import Dependencies
import Foundation
import JOSESwift
import Security

public enum HubLicenseVerificationError: Error, LocalizedError {
case invalidSignature
case malformed

public var errorDescription: String? {
switch self {
case .invalidSignature, .malformed:
return LocalizedString.getValue("hubAuthentication.license.error.invalidSignature")
}
}
}

public enum HubLicenseVerificationResult: Equatable {
case valid
case expired
}

public enum HubLicenseVerifier {
/// Static ES512 (P-521 / secp521r1) public key of Skymatic's License Server, base64-encoded SPKI (DER).
private static let licensePublicKeyBase64 = "MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBLJOU8YgKkP19EPV6p3eDlnpljZxDc2BXK+RPAb3caj2EuEH9a5ORaLAY+PjIkDPIQdHaa44Cbrzmug97bTyXTzQB97C90Utw0bzNkE22YwKdqWwKebUCSP3Tifxgn8JzrWb/9oI2D3q4+ZPzHZkty0SSM8kTwJwgT0wOwB4dj1GBEFc="

/// Lenient clock-skew allowance for the `exp` check, mirroring the backend's tolerance.
private static let leeway: TimeInterval = 60

private static let embeddedPublicKey: SecKey = {
do {
guard let derData = Data(base64Encoded: licensePublicKeyBase64) else {
throw HubLicenseVerificationError.malformed
}
let publicKey = try P521.Signing.PublicKey(derRepresentation: derData)
return try makeSecKey(x963Representation: publicKey.x963Representation, keyClass: kSecAttrKeyClassPublic)
} catch {
fatalError("Embedded License Server public key is invalid: \(error)")
}
}()

public static func verify(token: String) throws -> HubLicenseVerificationResult {
try verify(token: token, publicKey: embeddedPublicKey)
}

static func verify(token: String, publicKey: SecKey) throws -> HubLicenseVerificationResult {
let jws: JWS
do {
jws = try JWS(compactSerialization: token)
} catch {
throw HubLicenseVerificationError.malformed
}
guard let verifier = Verifier(verifyingAlgorithm: .ES512, key: publicKey) else {
throw HubLicenseVerificationError.malformed
}
guard jws.isValid(for: verifier) else {
throw HubLicenseVerificationError.invalidSignature
}
let expiration = try expirationDate(from: jws.payload)
if expiration < Date().addingTimeInterval(-leeway) {
return .expired
}
return .valid
}

static func makeSecKey(x963Representation: Data, keyClass: CFString) throws -> SecKey {
let attributes: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeyClass: keyClass
]
var error: Unmanaged<CFError>?
guard let secKey = SecKeyCreateWithData(x963Representation as CFData, attributes as CFDictionary, &error) else {
if let error = error?.takeRetainedValue() {
throw error
}
throw HubLicenseVerificationError.malformed
}
return secKey
}

private static func expirationDate(from payload: Payload) throws -> Date {
do {
let claims = try JSONDecoder().decode(LicenseClaims.self, from: payload.data())
return Date(timeIntervalSince1970: claims.exp)
} catch {
throw HubLicenseVerificationError.malformed
}
}

private struct LicenseClaims: Decodable {
let exp: TimeInterval
}
}

public protocol HubLicenseVerifying {
func verify(token: String) throws -> HubLicenseVerificationResult
}

struct LiveHubLicenseVerifier: HubLicenseVerifying {
func verify(token: String) throws -> HubLicenseVerificationResult {
try HubLicenseVerifier.verify(token: token)
}
}

private enum HubLicenseVerifyingDependencyKey: DependencyKey {
static let liveValue: HubLicenseVerifying = LiveHubLicenseVerifier()
#if DEBUG
static let testValue: HubLicenseVerifying = UnimplementedHubLicenseVerifier()
#endif
}

extension DependencyValues {
var hubLicenseVerifier: HubLicenseVerifying {
get { self[HubLicenseVerifyingDependencyKey.self] }
set { self[HubLicenseVerifyingDependencyKey.self] = newValue }
}
}

#if DEBUG
final class UnimplementedHubLicenseVerifier: HubLicenseVerifying {
func verify(token: String) throws -> HubLicenseVerificationResult {
unimplemented(placeholder: .valid)
}
}

// MARK: - HubLicenseVerifyingMock -

final class HubLicenseVerifyingMock: HubLicenseVerifying {
// MARK: - verify

var verifyTokenThrowableError: Error?
var verifyTokenCallsCount = 0
var verifyTokenCalled: Bool {
verifyTokenCallsCount > 0
}

var verifyTokenReceivedToken: String?
var verifyTokenReturnValue: HubLicenseVerificationResult!
var verifyTokenClosure: ((String) throws -> HubLicenseVerificationResult)?

func verify(token: String) throws -> HubLicenseVerificationResult {
if let error = verifyTokenThrowableError {
throw error
}
verifyTokenCallsCount += 1
verifyTokenReceivedToken = token
return try verifyTokenClosure.map({ try $0(token) }) ?? verifyTokenReturnValue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ final class HubAuthenticationViewModelTests: XCTestCase {
let hubKeyProviderMock = CryptomatorHubKeyProviderMock()

// GIVEN
// the hub key service returns success with an active Cryptomator Hub subscription state
// the hub key service returns success with an inactive Cryptomator Hub subscription state
hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = try .successMock(header: ["hub-subscription-state": "INACTIVE"])

let devicePrivKey = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB2bmFCWy2p+EbAn8NWS5Om+GA7c5LHhRZb8g2pSMSf0fsd7k7dZDVrnyHFiLdd/YGhZANiAAR6bsjTEdXKWIuu1Bvj6Y8wySlIROy7YpmVZTY128ItovCD8pcR4PnFljvAIb2MshCdr1alX4g6cgDOqcTeREiObcSfucOU9Ry1pJ/GnX6KA0eSljrk6rxjSDos8aiZ6Mg="
Expand Down Expand Up @@ -215,6 +215,133 @@ final class HubAuthenticationViewModelTests: XCTestCase {
XCTAssertEqual(receivedResponse?.subscriptionState, .inactive)
}

func testContinueToAccessCheck_iosLicenseValid_takesPrecedenceOverSubscriptionState() async throws {
let hubKeyProviderMock = CryptomatorHubKeyProviderMock()
let licenseVerifierMock = HubLicenseVerifyingMock()
licenseVerifierMock.verifyTokenReturnValue = .valid

// GIVEN
// the hub key service returns success with a valid Hub-iOS-License but an inactive legacy subscription state
hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = try .successMock(header: ["hub-ios-license": "license.jwt.token", "hub-subscription-state": "INACTIVE"])

let devicePrivKey = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB2bmFCWy2p+EbAn8NWS5Om+GA7c5LHhRZb8g2pSMSf0fsd7k7dZDVrnyHFiLdd/YGhZANiAAR6bsjTEdXKWIuu1Bvj6Y8wySlIROy7YpmVZTY128ItovCD8pcR4PnFljvAIb2MshCdr1alX4g6cgDOqcTeREiObcSfucOU9Ry1pJ/GnX6KA0eSljrk6rxjSDos8aiZ6Mg="
let data = try XCTUnwrap(Data(base64Encoded: devicePrivKey))
let privateKey = try P384.KeyAgreement.PrivateKey(pkcs8DerRepresentation: data)
hubKeyProviderMock.getPrivateKeyReturnValue = privateKey

// WHEN
// continue the access check
await withDependencies({
$0.hubKeyService = hubKeyServiceMock
$0.cryptomatorHubKeyProvider = hubKeyProviderMock
$0.hubLicenseVerifier = licenseVerifierMock
}, operation: {
await self.viewModel.continueToAccessCheck()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// THEN
// the unlock handler gets informed about the successful remote unlock with an active subscription state, ignoring the legacy header
let receivedResponse = unlockHandlerMock.didSuccessfullyRemoteUnlockReceivedResponse
XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 1)
XCTAssertEqual(receivedResponse?.subscriptionState, .active)
XCTAssertEqual(licenseVerifierMock.verifyTokenReceivedToken, "license.jwt.token")
}

func testContinueToAccessCheck_iosLicenseExpired_takesPrecedenceOverSubscriptionState() async throws {
let hubKeyProviderMock = CryptomatorHubKeyProviderMock()
let licenseVerifierMock = HubLicenseVerifyingMock()
licenseVerifierMock.verifyTokenReturnValue = .expired

// GIVEN
// the hub key service returns success with an expired Hub-iOS-License but an active legacy subscription state
hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = try .successMock(header: ["hub-ios-license": "license.jwt.token", "hub-subscription-state": "ACTIVE"])

let devicePrivKey = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB2bmFCWy2p+EbAn8NWS5Om+GA7c5LHhRZb8g2pSMSf0fsd7k7dZDVrnyHFiLdd/YGhZANiAAR6bsjTEdXKWIuu1Bvj6Y8wySlIROy7YpmVZTY128ItovCD8pcR4PnFljvAIb2MshCdr1alX4g6cgDOqcTeREiObcSfucOU9Ry1pJ/GnX6KA0eSljrk6rxjSDos8aiZ6Mg="
let data = try XCTUnwrap(Data(base64Encoded: devicePrivKey))
let privateKey = try P384.KeyAgreement.PrivateKey(pkcs8DerRepresentation: data)
hubKeyProviderMock.getPrivateKeyReturnValue = privateKey

// WHEN
// continue the access check
await withDependencies({
$0.hubKeyService = hubKeyServiceMock
$0.cryptomatorHubKeyProvider = hubKeyProviderMock
$0.hubLicenseVerifier = licenseVerifierMock
}, operation: {
await self.viewModel.continueToAccessCheck()
})

// THEN
// the unlock handler gets informed about the successful remote unlock with an inactive subscription state, ignoring the legacy header
let receivedResponse = unlockHandlerMock.didSuccessfullyRemoteUnlockReceivedResponse
XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 1)
XCTAssertEqual(receivedResponse?.subscriptionState, .inactive)
XCTAssertEqual(licenseVerifierMock.verifyTokenReceivedToken, "license.jwt.token")
}

func testContinueToAccessCheck_iosLicenseInvalidSignature_setsErrorStateAndDoesNotUnlock() async throws {
let hubKeyProviderMock = CryptomatorHubKeyProviderMock()
let licenseVerifierMock = HubLicenseVerifyingMock()
licenseVerifierMock.verifyTokenThrowableError = HubLicenseVerificationError.invalidSignature

// GIVEN
// the hub key service returns success with a Hub-iOS-License whose signature does not verify, alongside an active legacy subscription state
hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = try .successMock(header: ["hub-ios-license": "license.jwt.token", "hub-subscription-state": "ACTIVE"])

let devicePrivKey = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB2bmFCWy2p+EbAn8NWS5Om+GA7c5LHhRZb8g2pSMSf0fsd7k7dZDVrnyHFiLdd/YGhZANiAAR6bsjTEdXKWIuu1Bvj6Y8wySlIROy7YpmVZTY128ItovCD8pcR4PnFljvAIb2MshCdr1alX4g6cgDOqcTeREiObcSfucOU9Ry1pJ/GnX6KA0eSljrk6rxjSDos8aiZ6Mg="
let data = try XCTUnwrap(Data(base64Encoded: devicePrivKey))
let privateKey = try P384.KeyAgreement.PrivateKey(pkcs8DerRepresentation: data)
hubKeyProviderMock.getPrivateKeyReturnValue = privateKey

// WHEN
// continue the access check
await withDependencies({
$0.hubKeyService = hubKeyServiceMock
$0.cryptomatorHubKeyProvider = hubKeyProviderMock
$0.hubLicenseVerifier = licenseVerifierMock
}, operation: {
await self.viewModel.continueToAccessCheck()
})

// THEN
// the authentication flow state is set to error and the vault is not unlocked, without falling back to the legacy header
guard case .error = viewModel.authenticationFlowState else {
return XCTFail("Expected error state, got \(String(describing: viewModel.authenticationFlowState))")
}
XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 0)
}

func testContinueToAccessCheck_iosLicenseMissing_fallsBackToSubscriptionState() async throws {
let hubKeyProviderMock = CryptomatorHubKeyProviderMock()
let licenseVerifierMock = HubLicenseVerifyingMock()

// GIVEN
// the hub key service returns success without a Hub-iOS-License but with an active legacy subscription state
hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = try .successMock(header: ["hub-subscription-state": "ACTIVE"])

let devicePrivKey = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDB2bmFCWy2p+EbAn8NWS5Om+GA7c5LHhRZb8g2pSMSf0fsd7k7dZDVrnyHFiLdd/YGhZANiAAR6bsjTEdXKWIuu1Bvj6Y8wySlIROy7YpmVZTY128ItovCD8pcR4PnFljvAIb2MshCdr1alX4g6cgDOqcTeREiObcSfucOU9Ry1pJ/GnX6KA0eSljrk6rxjSDos8aiZ6Mg="
let data = try XCTUnwrap(Data(base64Encoded: devicePrivKey))
let privateKey = try P384.KeyAgreement.PrivateKey(pkcs8DerRepresentation: data)
hubKeyProviderMock.getPrivateKeyReturnValue = privateKey

// WHEN
// continue the access check
await withDependencies({
$0.hubKeyService = hubKeyServiceMock
$0.cryptomatorHubKeyProvider = hubKeyProviderMock
$0.hubLicenseVerifier = licenseVerifierMock
}, operation: {
await self.viewModel.continueToAccessCheck()
})

// THEN
// the legacy subscription state is used and the license verifier is not consulted
let receivedResponse = unlockHandlerMock.didSuccessfullyRemoteUnlockReceivedResponse
XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 1)
XCTAssertEqual(receivedResponse?.subscriptionState, .active)
XCTAssertFalse(licenseVerifierMock.verifyTokenCalled)
}

func testContinueToAccessCheck_accessNotGranted() async {
// GIVEN
// the hub key service returns access not granted
Expand Down
Loading
Loading