From 94da6ec6f7a185c7cfd472012ae2dd8d867bb1f8 Mon Sep 17 00:00:00 2001 From: Tobias Hagemann Date: Tue, 9 Jun 2026 22:26:59 +0200 Subject: [PATCH] Unlock Hub vaults in write mode via the Hub-iOS-License JWT --- .../Hub/HubAuthenticationViewModel.swift | 20 ++- .../Hub/HubLicenseVerifier.swift | 159 ++++++++++++++++++ .../Hub/HubAuthenticationViewModelTests.swift | 129 +++++++++++++- .../Hub/HubLicenseVerifierTests.swift | 136 +++++++++++++++ SharedResources/en.lproj/Localizable.strings | 1 + 5 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLicenseVerifier.swift create mode 100644 CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubLicenseVerifierTests.swift diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift index f5e4ab0f9..c579fade2 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift @@ -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? @@ -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, @@ -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 @@ -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") diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLicenseVerifier.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLicenseVerifier.swift new file mode 100644 index 000000000..2074d563c --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLicenseVerifier.swift @@ -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? + 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 + } +} +#endif diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift index b22be4c8a..6607228f5 100644 --- a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift @@ -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=" @@ -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() + }) + + // 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 diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubLicenseVerifierTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubLicenseVerifierTests.swift new file mode 100644 index 000000000..fff56d76d --- /dev/null +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubLicenseVerifierTests.swift @@ -0,0 +1,136 @@ +// +// HubLicenseVerifierTests.swift +// CryptomatorCommonCoreTests +// +// Created by Tobias Hagemann on 09.06.26. +// Copyright © 2026 Skymatic GmbH. All rights reserved. +// + +import CryptoKit +import Foundation +import JOSESwift +import Security +import XCTest +@testable import CryptomatorCommonCore + +final class HubLicenseVerifierTests: XCTestCase { + private var signingKey: P521.Signing.PrivateKey! + private var publicKey: SecKey! + + override func setUpWithError() throws { + signingKey = P521.Signing.PrivateKey() + publicKey = try HubLicenseVerifier.makeSecKey(x963Representation: signingKey.publicKey.x963Representation, keyClass: kSecAttrKeyClassPublic) + } + + func testValidSignatureNotExpired() throws { + let token = try makeToken(signingKey: signingKey, claims: ["exp": Date().addingTimeInterval(3600).timeIntervalSince1970]) + + let result = try HubLicenseVerifier.verify(token: token, publicKey: publicKey) + + XCTAssertEqual(result, .valid) + } + + func testValidSignatureExpired() throws { + let token = try makeToken(signingKey: signingKey, claims: ["exp": Date().addingTimeInterval(-3600).timeIntervalSince1970]) + + let result = try HubLicenseVerifier.verify(token: token, publicKey: publicKey) + + XCTAssertEqual(result, .expired) + } + + func testValidSignatureExpiredWithinLeewayStillValid() throws { + let token = try makeToken(signingKey: signingKey, claims: ["exp": Date().addingTimeInterval(-30).timeIntervalSince1970]) + + let result = try HubLicenseVerifier.verify(token: token, publicKey: publicKey) + + XCTAssertEqual(result, .valid) + } + + func testValidSignatureExpiredBeyondLeewayExpired() throws { + let token = try makeToken(signingKey: signingKey, claims: ["exp": Date().addingTimeInterval(-90).timeIntervalSince1970]) + + let result = try HubLicenseVerifier.verify(token: token, publicKey: publicKey) + + XCTAssertEqual(result, .expired) + } + + func testWrongKeyThrowsInvalidSignature() throws { + let otherKey = P521.Signing.PrivateKey() + let token = try makeToken(signingKey: otherKey, claims: ["exp": Date().addingTimeInterval(3600).timeIntervalSince1970]) + + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: token, publicKey: publicKey)) { error in + guard case HubLicenseVerificationError.invalidSignature = error else { + XCTFail("Unexpected error: \(error)") + return + } + } + } + + func testGarbledTokenThrowsMalformed() throws { + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: "not-a-valid-token", publicKey: publicKey)) { error in + guard case HubLicenseVerificationError.malformed = error else { + XCTFail("Unexpected error: \(error)") + return + } + } + } + + func testMissingExpirationThrowsMalformed() throws { + let token = try makeToken(signingKey: signingKey, claims: ["sub": "test"]) + + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: token, publicKey: publicKey)) { error in + guard case HubLicenseVerificationError.malformed = error else { + XCTFail("Unexpected error: \(error)") + return + } + } + } + + func testWrongAlgorithmThrowsInvalidSignature() throws { + let symmetricKey = Data(repeating: 0x01, count: 64) + let header = JWSHeader(algorithm: .HS256) + let payload = try Payload(JSONSerialization.data(withJSONObject: ["exp": Date().addingTimeInterval(3600).timeIntervalSince1970])) + let signer = try XCTUnwrap(Signer(signingAlgorithm: .HS256, key: symmetricKey)) + let token = try JWS(header: header, payload: payload, signer: signer).compactSerializedString + + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: token, publicKey: publicKey)) { error in + guard case HubLicenseVerificationError.invalidSignature = error else { + XCTFail("Unexpected error: \(error)") + return + } + } + } + + func testUnsignedAlgNoneTokenIsRejected() throws { + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: ["exp": Date().addingTimeInterval(3600).timeIntervalSince1970]) + let token = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())." + + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: token, publicKey: publicKey)) { error in + XCTAssertTrue(error is HubLicenseVerificationError, "Unexpected error: \(error)") + } + } + + func testEmbeddedPublicKeyRejectsForeignSignature() throws { + // exercises the embedded production key: it must parse into a usable EC key and reject a token signed by a different key + let token = try makeToken(signingKey: signingKey, claims: ["exp": Date().addingTimeInterval(3600).timeIntervalSince1970]) + + XCTAssertThrowsError(try HubLicenseVerifier.verify(token: token)) { error in + guard case HubLicenseVerificationError.invalidSignature = error else { + XCTFail("Unexpected error: \(error)") + return + } + } + } + + // MARK: - Internal + + private func makeToken(signingKey: P521.Signing.PrivateKey, claims: [String: Any]) throws -> String { + let privateKey = try HubLicenseVerifier.makeSecKey(x963Representation: signingKey.x963Representation, keyClass: kSecAttrKeyClassPrivate) + let header = JWSHeader(algorithm: .ES512) + let payload = try Payload(JSONSerialization.data(withJSONObject: claims)) + let signer = try XCTUnwrap(Signer(signingAlgorithm: .ES512, key: privateKey)) + let jws = try JWS(header: header, payload: payload, signer: signer) + return jws.compactSerializedString + } +} diff --git a/SharedResources/en.lproj/Localizable.strings b/SharedResources/en.lproj/Localizable.strings index cdb87ffea..84508f63d 100644 --- a/SharedResources/en.lproj/Localizable.strings +++ b/SharedResources/en.lproj/Localizable.strings @@ -138,6 +138,7 @@ "hubAuthentication.trustHost.error.httpNotAllowed" = "Insecure connection (HTTP) to \"%@\" is not allowed. Please use HTTPS."; "hubAuthentication.untrustedHost.alert.title" = "Host Not Trusted"; "hubAuthentication.vaultArchived" = "This vault has been archived. Please ask the vault owner to unarchive it."; +"hubAuthentication.license.error.invalidSignature" = "The Cryptomator Hub license could not be verified. Please inform a Hub administrator."; "intents.clearCache.description" = "Clears Cryptomator's local file cache."; "intents.clearCache.cacheCleared" = "Cache cleared.";