diff --git a/ALAMOFIRE-OPTIONS.md b/ALAMOFIRE-OPTIONS.md new file mode 100644 index 0000000..d6934db --- /dev/null +++ b/ALAMOFIRE-OPTIONS.md @@ -0,0 +1,52 @@ + +# AlamoFire Options +This provides some other options available with the AlamoFire networking stack. + +## Network Retry Options +The `ApproovInterceptor` class implements Alamofire's Interceptor protocol which includes an option to invoke a retry attempt in case the original request failed. We do not implement the retry option in `ApproovInterceptor`, but if you require implementing one, you should mimic the contents of the `adapt()` function and perhaps add some logic regarding retry attempts. See an example [here](https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests-with-requestinterceptor). + +## Trust Manager +The `ApproovSession` object internally handles the creation of a default `AproovTrustManager` that handles dynamic pinning. You may set your own `ServerTrustManager` during construction like so: + +```swift +let session = ApproovSession(serverTrustManager: manager) +``` +However, if you do this then Approov dynamic pinning WILL NOT be applied. + +An alternative is to use the `ApproovTrustManager` along with your own `ServerTrustEvaluating` implementations as follows: + +```swift +let evaluators: [String: ServerTrustEvaluating] = [ + "some.other.host.com": RevocationTrustEvaluator(), + "another.host": PinnedCertificatesTrustEvaluator() +] +let manager = ApproovTrustManager(allHostsMustBeEvaluated: true, evaluators: evaluators) +let session = ApproovSession(serverTrustManager: manager) +``` + +This approach will use the Approov dynamic pinning for all hosts that are being [mangaged](https://approov.io/docs/latest/approov-usage-documentation/#managing-api-domains) by Approov. Other host names will be passed to your custom evaluators. If you specify an evaluator that is also managed by Approov, then Approov will take precedence. + +### Alamofire Request +If your code makes use of the default Alamofire `Session`, like so: + +```swift +AF.request("https://httpbin.org/get").response { response in + debugPrint(response) +} +``` + +all you will need to do to use Approov is to replace the default `Session` object with the `ApproovSession`: + +```swift +let approovSession = ApproovSession() +approovSession!.request("https://httpbin.org/get").responseData { response in + debugPrint(response) +} +``` + +## Network Delegate +You may specify your own network delegate when the `ApproovSession` is constructed as follows: + +```swift +let session = ApproovSession(delegate: delegate) +``` diff --git a/ApproovSession.podspec b/ApproovSession.podspec index a62c2ed..19b00b8 100644 --- a/ApproovSession.podspec +++ b/ApproovSession.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "ApproovSession" - s.version = "3.5.4" + s.version = "3.5.5" s.summary = "Approov mobile attestation SDK" s.description = <<-DESC Approov SDK integrates security attestation and secure string fetching for both iOS and watchOS apps. @@ -9,7 +9,8 @@ Pod::Spec.new do |s| s.license = { :type => "Commercial", :file => "LICENSE" } s.authors = { "Approov, Ltd." => "support@approov.io" } s.source = { :git => "https://github.com/approov/approov-service-alamofire", :tag => s.version } - + s.module_name = 'ApproovAFSession' + # Supported platforms s.ios.deployment_target = '11.0' s.watchos.deployment_target = '9.0' @@ -18,6 +19,9 @@ Pod::Spec.new do |s| s.source_files = "Sources/ApproovSession/**/*.{swift,h}" # Dependency on the Approov SDK s.dependency 'approov-ios-sdk', '~> 3.5.3' + # Add dependency on swift-http-structured-headers + s.dependency 'swift-http-structured-headers', '~> 1.4.0' + s.dependency 'Alamofire', '~> 5.2.0' s.frameworks = 'Approov' # Pod target xcconfig settings if required s.pod_target_xcconfig = { diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..de60b00 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this package will be documented in this file. + +The format is based on Keep a Changelog and this project adheres to Semantic Versioning. + + +## [3.5.5] - 2026-03-06 + +### Fixed +- Made `loggingLevel` thread-safe with a dedicated `loggingQueue` to prevent data races on concurrent reads/writes. +- Gated all `os_log` calls in `ApproovTrustManager` behind `ApproovService.loggingLevel` so that `setLoggingLevel` controls all package logging consistently. +- Fixed logging level guard mismatch in `ApproovDefaultMessageSigning` (`.info` → `.error`) and gated additional debug logs. + +### Added +- Added `REFERENCE.md` and `USAGE.md` documentation to match other Approov Swift service layers. +- Separated `CHANGELOG.md` from the primary README for better discoverability. +- Introduced `ApproovServiceMutator` interface to allow customizing the behavior of the `ApproovService` during attestation and interception flows without forking the project. +- Added `setUseApproovStatusIfNoToken` configuration to `ApproovService`. When enabled, failure reasons (like `mitm_detected` or `no_network`) are placed in the Approov-Token header if a request proceeds without a valid token. +- Added `setLoggingLevel(_ level: ApproovLogLevel)` configuration for fine-grained control over the package's internal `os_log` statements. Supports `.off`, `.error`, `.warning`, `.info`, and `.debug`. +### Changed +- `ApproovDefaultMessageSigning` now gracefully skips signing and proceeds with the request without throwing an error if the install message signature is unavailable. +- `ApproovDefaultMessageSigning` now implements `ApproovServiceMutator` instead of `ApproovInterceptorExtensions`. +- `ApproovDefaultMessageSigning` now checks for the configured Approov token header via an internal synchronized accessor instead of assuming `Approov-Token`. +### Deprecated +- `ApproovInterceptorExtensions` was replaced by the much more robust `ApproovServiceMutator` protocol. The deprecated `setApproovInterceptorExtensions` API now forwards to `setServiceMutator` for backward compatibility. +### Removed +- `setProceedOnNetworkFailure()` has been removed. Use the `ApproovServiceMutator` instead to customize behavior on network failures. See `USAGE.md` for examples of how to implement a custom mutator. diff --git a/Package.resolved b/Package.resolved index fbf90c3..c4df131 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "3f99050e75bbc6fe71fc323adabb039756680016", + "version" : "5.11.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "f280fc7676b9940ff2c6598642751ea333c6544f", - "version" : "1.2.2" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } } ], diff --git a/Package.swift b/Package.swift index 36d46e9..609d869 100644 --- a/Package.swift +++ b/Package.swift @@ -2,12 +2,12 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription // Release tag -let releaseTAG = "3.5.4" +let releaseTAG = "3.5.5" // SDK package version (used for both iOS and watchOS) let sdkVersion = "3.5.3" let package = Package( - name: "ApproovSession", + name: "ApproovAFSession", platforms: [ .iOS(.v11), .watchOS(.v9) @@ -15,34 +15,30 @@ let package = Package( products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "ApproovSession", - targets: ["ApproovSession"] + name: "ApproovAFSession", + targets: ["ApproovAFSession"] ), - .library(name: "ApproovSessionDynamic", type: .dynamic, targets: ["ApproovSession"]) + .library(name: "ApproovAFSessionDynamic", type: .dynamic, targets: ["ApproovAFSession"]) ], dependencies: [ // Package's external dependencies and from where they can be fetched: .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.2.0")), - .package(url: "https://github.com/apple/swift-http-structured-headers.git", from: "1.0.0") + .package(url: "https://github.com/apple/swift-http-structured-headers.git", from: "1.0.0"), + // Force-unwrapping is safe here because sdkVersion is a valid semantic version string + .package(url: "https://github.com/approov/approov-ios-sdk.git", from: Version(sdkVersion)!) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "ApproovSession", + name: "ApproovAFSession", dependencies: [ - "Approov", - "Alamofire", + .product(name: "Approov", package: "approov-ios-sdk"), + .product(name: "Alamofire", package: "Alamofire"), .product(name: "RawStructuredFieldValues", package: "swift-http-structured-headers") ], path: "Sources/ApproovSession", // Point to the shared source code exclude: ["README.md", "LICENSE"] - ), - // Binary target for the merged xcframework - .binaryTarget( - name: "Approov", - url: "https://github.com/approov/approov-ios-sdk/releases/download/\(sdkVersion)/Approov.xcframework.zip", - checksum: "acfbee9e6c8009535c070c87c0d5756c2ba8f78e7b8147d7632f12bfcb940c89" // SHA256 checksum of the xcframework zip file ) ] ) diff --git a/README.md b/README.md index a8b0f4f..2bbac79 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,22 @@ A wrapper for the [Approov SDK](https://github.com/approov/approov-ios-sdk) to enable easy integration when using [`Alamofire`](https://github.com/Alamofire/Alamofire) for making the API calls that you wish to protect with Approov. In order to use this you will need a trial or paid [Approov](https://www.approov.io) account. Please see the [Quickstart](https://github.com/approov/quickstart-ios-swift-alamofire) for usage instructions. + +## Swift Package Manager Import + +When adding this package with Swift Package Manager, import the module as: + +```swift +import ApproovAFSession +``` + +The primary Alamofire session type remains `ApproovSession`. + +## Documentation + +This repository includes several Markdown files to help you understand and configure the Approov Service: + +- [**README.md**](README.md) - This file, providing a basic overview and import instructions. +- [**USAGE.md**](USAGE.md) - Detailed guide on the features and functionality of the Approov Service, including how to interact with the service layer, customize its behavior with `ApproovServiceMutator`, and setup token binding or message signing. +- [**ALAMOFIRE-OPTIONS.md**](ALAMOFIRE-OPTIONS.md) - Additional options specifically available with the Alamofire networking stack, such as network retry options and customizing the `Session`, `ServerTrustManager`, or network delegates. +- [**CHANGELOG.md**](CHANGELOG.md) - Record of all notable changes, new features, and bug fixes for each version of the package. diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..b342344 --- /dev/null +++ b/REFERENCE.md @@ -0,0 +1,324 @@ +# Reference + +This provides a reference for the public methods defined on `ApproovService`. These are available when you import the Swift Package Manager module: + +```swift +import ApproovAFSession +``` + +Most methods either throw an `ApproovError` or return an `ApproovUpdateResponse`. The error cases to be aware of are: + +- `initializationError`: An error occurred during Approov SDK initialization. +- `configurationError`: A configuration feature is disabled or wrongly configured (e.g. attempting to initialize with a different config from a previous instantiation). +- `pinningError`: A certificate error occurred during pinning. +- `networkingError`: A temporary networking issue; offer a retry. +- `permanentError`: Might be due to a feature not enabled using the command line. Non-retryable. +- `rejectionError`: An attestation has been rejected. The `ARC` and `rejectionReasons` may contain specific device information that would help troubleshooting. + +## initialize +Initializes the SDK with the config obtained using `approov sdk -getConfigString` or +in the original onboarding email. Note the initializer function should only ever be called once. +Subsequent calls will be ignored since the ApproovSDK can only be initialized once; if however, +an attempt is made to initialize with a different configuration (config) we throw an +ApproovError.configurationError. If the Approov SDK fails to be initialized for some other +reason, an `ApproovError.initializationError` is raised. + +```swift +try ApproovService.initialize(config: "") +``` + +Optional comment can be provided to configure the platform SDK: + +```swift +try ApproovService.initialize(config: "", comment: "my-comment") +``` + + +## setDevKey +Sets a development key indicating that the app is a development version and it should +pass attestation even if the app is not registered or it is running on an emulator. The +development key value can be rotated at any point in the account if a version of the app +containing the development key is accidentally released. This is primarily +used for situations where the app package must be modified or resigned in +some way as part of the testing process or when using Approov in a development +environment. + +```swift +ApproovService.setDevKey(devKey: "") +``` + +## setApproovHeader +Sets the header that the Approov token is added on, as well as an optional +prefix String (such as "Bearer "). By default the token is provided on +"Approov-Token" with no prefix. Message signing also uses this configured +header name to determine whether a request should be signed. + +```swift +ApproovService.setApproovHeader(header: "Approov-Token", prefix: "Bearer ") +``` + +## setApproovTraceIDHeader +Sets the header name used to carry the optional Approov TraceID. Pass `nil` to disable. + +```swift +ApproovService.setApproovTraceIDHeader(header: "Approov-TraceID") +ApproovService.setApproovTraceIDHeader(header: nil) +``` + +## getApproovTraceIDHeader +Returns the configured TraceID header name, or `nil` if disabled. + +```swift +let header = ApproovService.getApproovTraceIDHeader() +``` + +## setBindingHeader +Sets a binding header that must be present on all requests using the Approov service. A +header should be chosen whose value is unchanging for most requests (such as an +Authorization header). A hash of the header value is included in the issued Approov tokens +to bind them to the value. This may then be verified by the backend API integration. This +method should typically only be called once. + +```swift +ApproovService.setBindingHeader(header: "Authorization") +``` + +## setUseApproovStatusIfNoToken +Sets a flag indicating if the Approov fetch status (e.g. `NO_NETWORK`, `MITM_DETECTED`) should be +used as the token header value if the actual token fetch fails or returns an empty token. This allows +passing error condition information to the backend via the Approov-Token header, which might +otherwise be empty or missing. + +```swift +ApproovService.setUseApproovStatusIfNoToken(shouldUse: true) +``` + +## setLoggingLevel +Sets the service-layer logging level. This controls the verbosity of unified logging (`os_log`) output generated internally by the package. All log statements are gated by this level. + +Available levels (in order of increasing verbosity): +- `.off`: Disables all logging from the `ApproovService` package. +- `.error`: Only logs critical errors (e.g., initialization failures, missing pins). +- `.warning`: Logs warnings and errors (e.g., duplicated initializations with identical configurations). +- `.info` (Default): Logs informative events, configuration receipts, and the token states being set. +- `.debug`: Logs highly verbose tracing information for every request, initialization step, and token fetch. + +```swift +ApproovService.setLoggingLevel(.debug) +``` + +## setServiceMutator +Installs a service mutator to customize behavior at key points in the service flow. Pass `nil` to restore defaults. See the `USAGE.md` for more information and a custom mutator example implementation. + +```swift +ApproovService.setServiceMutator(myMutator) +ApproovService.setServiceMutator(nil) +``` + +## getServiceMutator +Gets the active service mutator instance that is handling callbacks from ApproovService. + +```swift +let mutator = ApproovService.getServiceMutator() +``` + +## setApproovInterceptorExtensions (deprecated) +Backwards-compatible API for message signing; use `setServiceMutator` instead. This deprecated API forwards to `setServiceMutator` for backward compatibility. + +```swift +ApproovService.setApproovInterceptorExtensions(myExtensions) +``` + +## getApproovInterceptorExtensions (deprecated) +Gets the interceptor extensions callback handlers. Backwards-compatible API for message signing; use `getServiceMutator` instead. + +```swift +let extensions = ApproovService.getApproovInterceptorExtensions() +``` + +## addSubstitutionHeader +Adds the name of a header which should be subject to secure strings substitution. This +means that if the header is present then the value will be used as a key to look up a +secure string value which will be substituted into the header value instead. This allows +easy migration to the use of secure strings. A required prefix may be specified to deal +with cases such as the use of `Bearer ` prefixed before values in an authorization header. + +```swift +ApproovService.addSubstitutionHeader(header: "Api-Key", prefix: nil) +ApproovService.addSubstitutionHeader(header: "Authorization", prefix: "Bearer ") +``` + +## removeSubstitutionHeader +Removes a header previously added for substitution. + +```swift +ApproovService.removeSubstitutionHeader(header: "Api-Key") +``` + +## addSubstitutionQueryParam +Adds a key name for a query parameter that should be subject to secure strings substitution. +This means that if the query parameter is present in a URL then the value will be used as a +key to look up a secure string value which will be substituted as the query parameter value +instead. This allows easy migration to the use of secure strings. + +```swift +ApproovService.addSubstitutionQueryParam(key: "api_key") +``` + +## removeSubstitutionQueryParam +Removes a query parameter key name previously added using addSubstitutionQueryParam. + +```swift +ApproovService.removeSubstitutionQueryParam(key: "api_key") +``` + +## addExclusionURLRegex +Adds an exclusion URL regular expression. If a URL for a request matches this regular expression +then it will not be subject to any Approov protection. Note that this facility must be used with +EXTREME CAUTION due to the impact of dynamic pinning. Pinning may be applied to all domains added +using Approov, and updates to the pins are received when an Approov fetch is performed. If you +exclude some URLs on domains that are protected with Approov, then these will be protected with +Approov pins but without a path to update the pins until a URL is used that is not excluded. Thus +you are responsible for ensuring that there is always a possibility of calling a non-excluded +URL, or you should make an explicit call to fetchToken if there are persistent pinning failures. +Conversely, use of those option may allow a connection to be established before any dynamic pins +have been received via Approov, thus potentially opening the channel to a MitM. + +```swift +ApproovService.addExclusionURLRegex(urlRegex: "^https://example\\.com/unprotected/.*$") +``` + +## removeExclusionURLRegex +Removes an exclusion URL regular expression previously added using addExclusionURLRegex. + +```swift +ApproovService.removeExclusionURLRegex(urlRegex: "^https://example\\.com/unprotected/.*$") +``` + +## getExclusionURLRegexs +Gets a copy of the current exclusion URL regexs. + +```swift +let regexs = ApproovService.getExclusionURLRegexs() +``` + +## prefetch +*OBSOLETE* This method is now automatically called when the service is initialized. +Starts a background token fetch to reduce latency for the next request. + +```swift +ApproovService.prefetch() +``` + +## precheck +Performs a precheck to determine if the app will pass attestation. This requires secure +strings to be enabled for the account, although no strings need to be set up. This will +likely require network access so may take some time to complete. It may throw an exception +if the precheck fails or if there is some other problem. Exceptions could be due to +a rejection (throws an `ApproovError.rejectionError`) type which might include additional +information regarding the rejection reason. An `ApproovError.networkingError` exception should +allow a retry operation to be performed and finally if some other error occurs an +`ApproovError.permanentError` is raised. Useful during development to check if the app will pass attestation. + +```swift +try ApproovService.precheck() +``` + +## getDeviceID +Gets the device ID used by Approov to identify the particular device that the SDK is running on. Note +that different Approov apps on the same device will return a different ID. Moreover, the ID may be +changed by an uninstall and reinstall of the app. + +```swift +let deviceId = ApproovService.getDeviceID() +``` + +## setDataHashInToken +Directly sets the data hash to be included in subsequently fetched Approov tokens. If the hash is +different from any previously set value then this will cause the next token fetch operation to +fetch a new token with the correct payload data hash. The hash appears in the +'pay' claim of the Approov token as a base64 encoded string of the SHA256 hash of the +data. Note that the data is hashed locally and never sent to the Approov cloud service. This method is an alternative to `setBindingHeader`. While both methods bind a header value to a token, this function sets the bound value directly, whereas `setBindingHeader` uses the value from a specified header. You should use one or the other, but not both. + +```swift +ApproovService.setDataHashInToken(data: "") +``` + +## fetchToken +Performs an Approov token fetch for the given URL. This should be used in situations where it +is not possible to use the networking interception to add the token. This will +likely require network access so may take some time to complete. If the attestation fails +for any reason then an `ApproovError` is thrown. This will be `ApproovError.networkingError` for +networking issues where a user initiated retry of the operation should be allowed. Note that +the returned token should *NEVER* be cached by your app, you should call this function when +it is needed. + +```swift +let token = try ApproovService.fetchToken(url: "https://example.com/api") +``` + +## getMessageSignature +*OBSOLETE* Returns a message signature using the account message signing key. Use `getAccountMessageSignature` or `getInstallMessageSignature` instead. + +```swift +let signature = ApproovService.getMessageSignature(message: message) +``` + +## getAccountMessageSignature +Gets the signature for the given message using the account-specific signing key. +This key is transmitted to the SDK after a successful fetch if the feature is enabled. + +```swift +let signature = ApproovService.getAccountMessageSignature(message: message) +``` + +## getInstallMessageSignature +Gets the signature for the given message using the install-specific signing key. +This key is tied to the specific app installation and is transmitted after a successful fetch. + +```swift +let signature = ApproovService.getInstallMessageSignature(message: message) +``` + +## fetchSecureString +Fetches a secure string with the given key. If newDef is not nil then a secure string for +the particular app instance may be defined. In this case the new value is returned as the +secure string. Use of an empty string for newDef removes the string entry. Note that this +call may require network transaction and thus may block for some time, so should not be called +from the UI thread. If the attestation fails for any reason then an exception is raised. Note +that the returned string should *NEVER* be cached by your app, you should call this function when +it is needed. If the fetch fails for any reason an exception is thrown with description. +A rejection throws an `ApproovError.rejectionError` type which might include additional information +regarding the failure reason. An `ApproovError.networkingError` exception should allow a retry operation to be performed and finally, if some other error occurs, an `ApproovError.permanentError` is raised. + +```swift +let value = try ApproovService.fetchSecureString(key: "api_key", newDef: nil) +``` + +## fetchCustomJWT +Fetches a custom JWT with the given payload. Note that this call will require network +transaction and thus will block for some time, so should not be called from the UI thread. +If the fetch fails for any reason an exception will be thrown. Exceptions could be due to +malformed JSON string provided (then an `ApproovError.permanentError` is raised), a rejection throws +an ApproovError.rejectionError type which might include additional information regarding the failure +reason. An `ApproovError.networkingError` exception should allow a retry operation to be performed. If +some other error occurs an `ApproovError.permanentError` is raised. + +```swift +let jwt = try ApproovService.fetchCustomJWT(payload: "{\"claims\":{...}}") +``` + +## getLastARC +Gets the last [Attestation Response Code](https://ext.approov.io/docs/latest/approov-usage-documentation/#attestation-response-code) code. *WARNING* The ARC code should ideally be returned from your server as part of a rejected API call (such as for an invalid JWT token or missing token). However, if you are unable to customize your server response to include the ARC code (for example, when using a WAF service), you can use this method to obtain the ARC code. Be aware that if the device has recently experienced a network transition or temporary connectivity loss and a request has been made without an Approov Token, you might receive an incorrect or outdated ARC code from this method if connectivity is available at the time the call is made. + +```swift +let arc = ApproovService.getLastARC() +``` + +## updateRequestWithApproov +Updates a `URLRequest` with Approov protection (token, substitutions, etc.). Returns an `ApproovUpdateResponse` describing the decision and any error. Used internally by the `ApproovSession` and Alamofire interceptors to protect the networking traffic inline. + +```swift +let response = ApproovService.updateRequestWithApproov(request) +``` + diff --git a/Sources/ApproovSession/ApproovDefaultMessageSigning.swift b/Sources/ApproovSession/ApproovDefaultMessageSigning.swift index 847fe85..73f9d6d 100644 --- a/Sources/ApproovSession/ApproovDefaultMessageSigning.swift +++ b/Sources/ApproovSession/ApproovDefaultMessageSigning.swift @@ -24,7 +24,7 @@ import os.log * message signatures to HTTP requests based on specified parameters and * algorithms. */ -public class ApproovDefaultMessageSigning: ApproovInterceptorExtensions { +public class ApproovDefaultMessageSigning: ApproovServiceMutator { /** * Constant for the SHA-256 digest algorithm (used for body digests). @@ -109,9 +109,9 @@ public class ApproovDefaultMessageSigning: ApproovInterceptorExtensions { * - Returns: The processed HTTP request with the signature headers added. * - Throws: An `ApproovError` if an error occurs during processing. */ - public func processedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest { + public func handleInterceptorProcessedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest { // If the request doesn't have an Approov token, we don't need to sign it - if (request.allHTTPHeaderFields?["Approov-Token"]) != nil { + if (request.allHTTPHeaderFields?[ApproovService.getApproovTokenHeader()]) != nil { // Generate and add a message signature let provider = ApproovURLSessionComponentProvider(request: request) guard let params = try buildSignatureParameters(provider: provider, changes: changes) else { @@ -132,7 +132,10 @@ public class ApproovDefaultMessageSigning: ApproovInterceptorExtensions { sigId = "install" guard let base64Signature = ApproovService.getInstallMessageSignature(message: message), let decodedSignature = Data(base64Encoded: base64Signature) else { - throw ApproovError.permanentError(message: "Failed to generate ES256 signature") + if ApproovService.loggingLevel >= .error { + os_log("ApproovService: install message signature unavailable, skipping signing", type: .error) + } + return request } // decode the signature from ASN.1 DER format signature = try ApproovDefaultMessageSigning.decodeASN_1_DER_ES256_Signature(decodedSignature) @@ -171,7 +174,9 @@ public class ApproovDefaultMessageSigning: ApproovInterceptorExtensions { if let sigBaseDigestHeader = try SFV.serializeDictionary(key: "sha-256", data: digest) { signedRequest.addValue(sigBaseDigestHeader, forHTTPHeaderField: "Signature-Base-Digest") } else { - os_log("ApproovService: Failed to get digest algorithm - no debug entry", type: .debug) + if ApproovService.loggingLevel >= .debug { + os_log("ApproovService: Failed to get digest algorithm - no debug entry", type: .debug) + } } } @@ -302,7 +307,9 @@ public class ApproovDefaultMessageSigning: ApproovInterceptorExtensions { try defaultSignatureParametersFactory.setBodyDigestConfig(ApproovDefaultMessageSigning.DIGEST_SHA256, required: false) } catch { // ApproovDefaultMessageSigning.DIGEST_SHA256 is a supported body digest algorithm - will never throw - os_log("ApproovDefaultMessageSigning - generateDefaultSignatureParametersFactory: Failed to set default body digest algorithm", type: .error) + if ApproovService.loggingLevel >= .error { + os_log("ApproovDefaultMessageSigning - generateDefaultSignatureParametersFactory: Failed to set default body digest algorithm", type: .error) + } } return defaultSignatureParametersFactory } @@ -661,3 +668,11 @@ class ApproovURLSessionComponentProvider: ComponentProvider { return request.httpBody != nil || request.httpBodyStream != nil } } + +@available(*, deprecated, message: "Use ApproovServiceMutator instead.") +extension ApproovDefaultMessageSigning: ApproovInterceptorExtensions { + @available(*, deprecated, message: "Use handleInterceptorProcessedRequest instead.") + public func processedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest { + return try handleInterceptorProcessedRequest(request, changes: changes) + } +} diff --git a/Sources/ApproovSession/ApproovInterceptorExtensions.swift b/Sources/ApproovSession/ApproovInterceptorExtensions.swift index 7b81e1f..dd2f0ce 100644 --- a/Sources/ApproovSession/ApproovInterceptorExtensions.swift +++ b/Sources/ApproovSession/ApproovInterceptorExtensions.swift @@ -20,8 +20,13 @@ import Foundation * ApproovInterceptorExtensions provides an interface for handling callbacks during * the processing of network requests by Approov. It allows further modifications * to requests after Approov has applied its changes. + * + * @deprecated Replace implementations of this protocol with ApproovServiceMutator + * while changing the name of the ApproovInterceptorExtensions.processedRequest + * method to ApproovServiceMutator.handleInterceptorProcessedRequest. */ -public protocol ApproovInterceptorExtensions { +@available(*, deprecated, message: "Replace implementations of this protocol with ApproovServiceMutator.") +public protocol ApproovInterceptorExtensions: ApproovServiceMutator { /** * Called after Approov has processed a network request, allowing further modifications. @@ -32,5 +37,18 @@ public protocol ApproovInterceptorExtensions { * - Returns: The modified request. * - Throws: An `ApproovException` if there is an error during processing. */ + @available(*, deprecated, message: "Use handleInterceptorProcessedRequest instead.") func processedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest } + +public extension ApproovInterceptorExtensions { + func handleInterceptorProcessedRequest(_ request: URLRequest, + changes: ApproovRequestMutations) throws -> URLRequest { + return try processedRequest(request, changes: changes) + } + + @available(*, deprecated, message: "Use handleInterceptorProcessedRequest instead.") + func processedRequest(_ request: URLRequest, changes: ApproovRequestMutations) throws -> URLRequest { + return request + } +} diff --git a/Sources/ApproovSession/ApproovService.swift b/Sources/ApproovSession/ApproovService.swift index 63aed5a..b73a531 100644 --- a/Sources/ApproovSession/ApproovService.swift +++ b/Sources/ApproovSession/ApproovService.swift @@ -68,6 +68,18 @@ public struct ApproovUpdateResponse { var error: Error? } +// Log level for controlling the verbosity of os_log output from the ApproovService +public enum ApproovLogLevel: Int, Comparable { + case off = 0 + case error = 1 + case warning = 2 + case info = 3 + case debug = 4 + public static func < (lhs: ApproovLogLevel, rhs: ApproovLogLevel) -> Bool { + return lhs.rawValue < rhs.rawValue + } +} + // ApproovService provides a mediation layer to the Approov SDK itself public class ApproovService { // private initializer @@ -85,9 +97,6 @@ public class ApproovService { // the dispatch queue to manage serial access to other ApproovService state private static let stateQueue = DispatchQueue(label: "ApproovService.state", qos: .userInitiated) - // if we should proceed on network fail - private static var proceedOnNetworkFail = false - // binding header name private static var bindingHeader = "" @@ -100,8 +109,23 @@ public class ApproovService { // Approov TraceID optional header private static var approovTraceIDHeader: String? = "Approov-TraceID" - // the target for request processing interceptorExtensions - private static var interceptorExtensions: ApproovInterceptorExtensions? = nil + // the target for request processing serviceMutator + private static var serviceMutator: ApproovServiceMutator = ApproovServiceMutatorDefault.shared + + // whether to place ApproovTokenFetchStatus inside the Token header when an error occurs or a token is empty + private static var useApproovStatusIfNoToken = false + + // dedicated queue for thread-safe access to the logging level (separate from stateQueue to + // avoid nested sync deadlocks when logging is checked inside stateQueue-protected methods) + private static let loggingQueue = DispatchQueue(label: "ApproovService.logging", qos: .userInitiated) + private static var _loggingLevel: ApproovLogLevel = .info + + // whether to log to the unified logging system + // internal access so ApproovDefaultMessageSigning can read the level + static var loggingLevel: ApproovLogLevel { + get { loggingQueue.sync { _loggingLevel } } + set { loggingQueue.sync { _loggingLevel = newValue } } + } // map of headers that should have their values substituted for secure strings, mapped to their // required prefixes @@ -132,10 +156,14 @@ public class ApproovService { // ignore multiple initialization calls that use the same configuration if (config != configString) { // throw exception indicating we are attempting to use different config - os_log("ApproovService: Attempting to initialize with different configuration", type: .error) + if loggingLevel >= .error { + os_log("ApproovService: Attempting to initialize with different configuration", type: .error) + } throw ApproovError.configurationError(message: "Attempting to initialize with a different configuration") } - os_log("ApproovService: Ignoring multiple ApproovService layer initializations with the same config"); + if loggingLevel >= .warning { + os_log("ApproovService: Ignoring multiple ApproovService layer initializations with the same config"); + } } else { do { if !config.isEmpty { @@ -143,17 +171,19 @@ public class ApproovService { try Approov.initialize(config, updateConfig: "auto", comment: comment) } } catch let error { - // If the error is due to the SDK being initilized already, we ignore it otherwise we throw - if error.localizedDescription.localizedCaseInsensitiveContains("Approov SDK already initialized") { - os_log("ApproovService: Ignoring initialization error in Approov SDK: %@", type: .error, error.localizedDescription) - isInitialized = true + // If the error is due to the SDK being initialized already, we ignore it otherwise we throw + let nsError = error as NSError + if nsError.code == 0, nsError.domain == "Foundation._GenericObjCError" { + if loggingLevel >= .error { + os_log("ApproovService: Ignoring initialization error in Approov SDK: %@", type: .error, nsError.localizedDescription) + } } else { - throw ApproovError.initializationError(message: "Error initializing Approov SDK: \(error.localizedDescription)") + throw ApproovError.initializationError(message: "Error initializing Approov SDK: \(nsError.localizedDescription)") } } isInitialized = true configString = config - Approov.setUserProperty("approov-service-urlsession") + Approov.setUserProperty("approov-service-alamofire") } } } @@ -167,7 +197,9 @@ public class ApproovService { // We have to get the current config and obtain one protected API endpoint at least // get the dynamic pins from Approov guard let approovPins = Approov.getPins("public-key-sha256") else { - os_log("ApproovService: no host pinning information available", type: .error) + if loggingLevel >= .error { + os_log("ApproovService: no host pinning information available", type: .error) + } return "" } // The approovPins contains a map of hostnames to pin strings. We need to skip the '*' entry (Managed Trust Roots), @@ -179,26 +211,11 @@ public class ApproovService { return result.arc } } - os_log("ApproovService: ARC code unavailable", type: .info) - return "" - } - - /** - * Sets a flag indicating if the network interceptor should proceed anyway if it is - * not possible to obtain an Approov token due to a networking failure. If this is set - * then your backend API can receive calls without the expected Approov token header - * being added, or without header/query parameter substitutions being made. Note that - * this should be used with caution because it may allow a connection to be established - * before any dynamic pins have been received via Approov, thus potentially opening the - * channel to a MitM. - * - * @param proceed is true if Approov networking fails should allow continuation - */ - public static func setProceedOnNetworkFailure(proceed: Bool) { - stateQueue.sync { - proceedOnNetworkFail = proceed - os_log("ApproovService: setProceedOnNetworkFailure ", type: .info, proceed) + + if loggingLevel >= .info { + os_log("ApproovService: ARC code unavailable", type: .info) } + return "" } /** @@ -214,7 +231,9 @@ public class ApproovService { public static func setDevKey(devKey: String) { stateQueue.sync { Approov.setDevKey(devKey) - os_log("ApproovService: setDevKey") + if loggingLevel >= .debug { + os_log("ApproovService: setDevKey") + } } } @@ -230,7 +249,20 @@ public class ApproovService { stateQueue.sync { approovTokenHeader = header approovTokenPrefix = prefix - os_log("ApproovService: setApproovHeader: %@", type: .debug, header, prefix) + if loggingLevel >= .debug { + os_log("ApproovService: setApproovHeader: %@ %@", type: .debug, header, prefix) + } + } + } + + /** + * Gets the header that is used to add the Approov token. + * + * @return the name of the header used for the Approov token + */ + static func getApproovTokenHeader() -> String { + return stateQueue.sync { + return approovTokenHeader } } @@ -243,7 +275,9 @@ public class ApproovService { public static func setApproovTraceIDHeader(header: String?) { stateQueue.sync { approovTraceIDHeader = header - os_log("ApproovService: setApproovTraceIDHeader: %@", type: .debug, header ?? "nil") + if loggingLevel >= .debug { + os_log("ApproovService: setApproovTraceIDHeader: %@", type: .debug, header ?? "nil") + } } } @@ -270,31 +304,96 @@ public class ApproovService { public static func setBindingHeader(header: String) { stateQueue.sync { bindingHeader = header - os_log("ApproovService: setBindingHeader: %@", type: .debug, header) + if loggingLevel >= .debug { + os_log("ApproovService: setBindingHeader: %@", type: .debug, header) + } } } /** - * Sets the interceptor extensions callback handler. This facility was introduced to support - * message signing that is independent from the rest of the attestation flow. The default - * ApproovService layer issues no callbacks, provide a non-null ApproovInterceptorExtensions - * handler to add functionality to the attestation flow. + * Sets the ApproovServiceMutator instance to handle callbacks from the + * ApproovService implementation. This facility enables customization of + * ApproovService operations at key points in the configuration and + * attestation flows. It should reduce the number of times this service + * layer implementation needs to be forked in order to introduce custom + * behavior. * - * @param callbacks is the configuration used to control message signing. The behaviour of the - * provided configuration must remain constant while in use by the ApproovService. - * Passing null to this method will disable message signing. + * @param mutator is the ApproovServiceMutator with callback handlers that may + * override the default behavior of the ApproovService singleton. + * Passing nil to this method will reinstate the default behavior. */ - public static func setApproovInterceptorExtensions(_ callbacks: ApproovInterceptorExtensions?) { - if callbacks == nil { - os_log("Interceptor extension disabled", type: .debug) - } else { - os_log("Interceptor extension enabled", type: .debug) + public static func setServiceMutator(_ mutator: ApproovServiceMutator?) { + let appliedMutator = mutator ?? ApproovServiceMutatorDefault.shared + if loggingLevel >= .debug { + os_log("Applied ApproovServiceMutator: %@", type: .debug, String(describing: appliedMutator)) } stateQueue.sync { - interceptorExtensions = callbacks + serviceMutator = appliedMutator + } + } + + /** + * Gets the active service mutator instance that is handling callbacks from ApproovService. + * + * @return the service mutator instance (never nil) + */ + public static func getServiceMutator() -> ApproovServiceMutator { + return stateQueue.sync { + return serviceMutator } } + /** + * Sets a flag indicating if the Approov fetch status should be used as the token header value + * if the actual token fetch fails or returns an empty token. This allows your backend to + * distinguish between different failure reasons (e.g., NO_NETWORK, MITM_DETECTED) even when + * the Approov-Token would otherwise be empty or missing. + * + * @param shouldUse the use status boolean + */ + public static func setUseApproovStatusIfNoToken(shouldUse: Bool) { + stateQueue.sync { + useApproovStatusIfNoToken = shouldUse + if loggingLevel >= .info { + os_log("ApproovService: setUseApproovStatusIfNoToken %@", type: .info, shouldUse ? "YES" : "NO") + } + } + } + + /** + * Sets the service-layer logging level. + * + * This controls all logging emitted by the ApproovService layer. Set to `.debug` + * when collecting diagnostics for customer issues. + * + * @param level the desired severity level + */ + public static func setLoggingLevel(_ level: ApproovLogLevel) { + loggingLevel = level + if level >= .info { + os_log("ApproovService: logging level set to %d", type: .info, level.rawValue) + } + } + + /** + * @deprecated Use setServiceMutator instead. + */ + @available(*, deprecated, message: "Use setServiceMutator instead.") + public static func setApproovInterceptorExtensions(_ callbacks: ApproovInterceptorExtensions?) { + setServiceMutator(callbacks) + } + + /** + * Gets the interceptor extensions callback handlers. + * + * @return the interceptor extensions callback handlers or nil if none set + * @deprecated Use getServiceMutator instead. + */ + @available(*, deprecated, message: "Use getServiceMutator instead.") + public static func getApproovInterceptorExtensions() -> ApproovInterceptorExtensions? { + return getServiceMutator() as? ApproovInterceptorExtensions + } + /** * Adds the name of a header which should be subject to secure strings substitution. This * means that if the header is present then the value will be used as a key to look up a @@ -309,10 +408,14 @@ public class ApproovService { stateQueue.sync { if prefix == nil { substitutionHeaders[header] = "" - os_log("ApproovService: addSubstitutionHeader: %@", type: .debug, header) + if loggingLevel >= .debug { + os_log("ApproovService: addSubstitutionHeader: %@", type: .debug, header) + } } else { substitutionHeaders[header] = prefix - os_log("ApproovService: addSubstitutionHeader: %@ %@", type: .debug, header, prefix!) + if loggingLevel >= .debug { + os_log("ApproovService: addSubstitutionHeader: %@ %@", type: .debug, header, prefix!) + } } } } @@ -327,7 +430,9 @@ public class ApproovService { if substitutionHeaders[header] != nil { substitutionHeaders.removeValue(forKey: header) } - os_log("ApproovService: removeSubstitutionHeader: %@", type: .debug, header) + if loggingLevel >= .debug { + os_log("ApproovService: removeSubstitutionHeader: %@", type: .debug, header) + } } } @@ -344,7 +449,9 @@ public class ApproovService { public static func addSubstitutionQueryParam(key: String) { stateQueue.sync { substitutionQueryParams.insert(key) - os_log("ApproovService: addSubstitutionQueryParam: %@", type: .debug, key) + if loggingLevel >= .debug { + os_log("ApproovService: addSubstitutionQueryParam: %@", type: .debug, key) + } } } @@ -356,7 +463,9 @@ public class ApproovService { public static func removeSubstitutionQueryParam(key: String) { stateQueue.sync { substitutionQueryParams.remove(key) - os_log("ApproovService: removeSubstitutionQueryParam: %@", type: .debug, key) + if loggingLevel >= .debug { + os_log("ApproovService: removeSubstitutionQueryParam: %@", type: .debug, key) + } } } @@ -379,9 +488,13 @@ public class ApproovService { do { let regex = try NSRegularExpression(pattern: urlRegex, options: []) exclusionURLRegexs[urlRegex] = regex - os_log("ApproovService: addExclusionURLRegex: %@", type: .debug, urlRegex) + if loggingLevel >= .debug { + os_log("ApproovService: addExclusionURLRegex: %@", type: .debug, urlRegex) + } } catch { - os_log("ApproovService: addExclusionURLRegex: %@ error: %@", type: .debug, urlRegex, error.localizedDescription) + if loggingLevel >= .debug { + os_log("ApproovService: addExclusionURLRegex: %@ error: %@", type: .debug, urlRegex, error.localizedDescription) + } } } } @@ -395,11 +508,24 @@ public class ApproovService { stateQueue.sync { if exclusionURLRegexs[urlRegex] != nil { exclusionURLRegexs.removeValue(forKey: urlRegex) - os_log("ApproovService: removeExclusionURLRegex: %@", type: .debug, urlRegex) + if loggingLevel >= .debug { + os_log("ApproovService: removeExclusionURLRegex: %@", type: .debug, urlRegex) + } } } } + /** + * Gets a copy of the current exclusion URL regexs. + * + * @return Dictionary of the exclusion regexs to their respective patterns. + */ + public static func getExclusionURLRegexs() -> Dictionary { + return stateQueue.sync { + return exclusionURLRegexs + } + } + /** * Allows an Approov fetch operation to be performed as early as possible. This * permits a token or secure strings to be available while an application might @@ -411,9 +537,13 @@ public class ApproovService { if isInitialized { Approov.fetchToken({(approovResult: ApproovTokenFetchResult) in if approovResult.status == ApproovTokenFetchStatus.unknownURL { - os_log("ApproovService: prefetch: success", type: .debug) + if loggingLevel >= .debug { + os_log("ApproovService: prefetch: success", type: .debug) + } } else { - os_log("ApproovService: prefetch: %@", type: .debug, Approov.string(from: approovResult.status)) + if loggingLevel >= .debug { + os_log("ApproovService: prefetch: %@", type: .debug, Approov.string(from: approovResult.status)) + } } }, "approov.io") } @@ -436,26 +566,18 @@ public class ApproovService { // try to fetch a non-existent secure string in order to check for a rejection let approovResults = Approov.fetchSecureStringAndWait("precheck-dummy-key", nil) if approovResults.status == ApproovTokenFetchStatus.unknownKey { - os_log("ApproovService: precheck: success", type: .debug) + if loggingLevel >= .debug { + os_log("ApproovService: precheck: success", type: .debug) + } } else { - os_log("ApproovService: precheck: %@", type: .debug, Approov.string(from: approovResults.status)) + if loggingLevel >= .debug { + os_log("ApproovService: precheck: %@", type: .debug, Approov.string(from: approovResults.status)) + } } - // process the returned Approov status - if approovResults.status == ApproovTokenFetchStatus.rejected { - // if the request is rejected then we provide a special exception with additional information - throw ApproovError.rejectionError(message: "precheck: rejected", ARC: approovResults.arc, - rejectionReasons: approovResults.rejectionReasons) - } else if approovResults.status == ApproovTokenFetchStatus.noNetwork || - approovResults.status == ApproovTokenFetchStatus.poorNetwork || - approovResults.status == ApproovTokenFetchStatus.mitmDetected { - // we are unable to get the secure string due to network conditions so the request can - // be retried by the user later - throw ApproovError.networkingError(message: "precheck network error: " + Approov.string(from: approovResults.status)) - } else if (approovResults.status != ApproovTokenFetchStatus.success) && (approovResults.status != ApproovTokenFetchStatus.unknownKey) { - // we are unable to get the secure string due to a more permanent error - throw ApproovError.permanentError(message: "precheck: " + Approov.string(from: approovResults.status)) - } + // process the returned Approov status using the mutator + let mutator = stateQueue.sync { return serviceMutator } + try mutator.handlePrecheckResult(approovResults) } /** @@ -468,7 +590,9 @@ public class ApproovService { public static func getDeviceID() -> String? { let deviceID = Approov.getDeviceID() if (deviceID != nil) { - os_log("ApproovService: getDeviceID %@", type: .debug, deviceID!) + if loggingLevel >= .debug { + os_log("ApproovService: getDeviceID %@", type: .debug, deviceID!) + } } return deviceID } @@ -483,7 +607,9 @@ public class ApproovService { * @param data is the data to be hashed and set in the token */ public static func setDataHashInToken(data: String) { - os_log("ApproovService: setDataHashInToken", type: .debug) + if loggingLevel >= .debug { + os_log("ApproovService: setDataHashInToken", type: .debug) + } Approov.setDataHashInToken(data) } @@ -503,22 +629,14 @@ public class ApproovService { public static func fetchToken(url: String) throws -> String { // fetch the Approov token let result: ApproovTokenFetchResult = Approov.fetchTokenAndWait(url) - os_log("ApproovService: fetchToken: %@", type: .debug, Approov.string(from: result.status)) - - // process the status - switch result.status { - case .success: - // provide the Approov token result - return result.token - case .noNetwork, - .poorNetwork, - .mitmDetected: - // we are unable to get an Approov token due to network conditions - throw ApproovError.networkingError(message: "fetchToken network error: " + Approov.string(from: result.status)) - default: - // we have failed to get an Approov token due to a more permanent error - throw ApproovError.permanentError(message: "fetchToken: " + Approov.string(from: result.status)) + if loggingLevel >= .debug { + os_log("ApproovService: fetchToken: %@", type: .debug, Approov.string(from: result.status)) } + + // process the status using the mutator + let mutator = stateQueue.sync { return serviceMutator } + try mutator.handleFetchTokenResult(result) + return result.token } /** @@ -541,7 +659,9 @@ public class ApproovService { * @return String of the base64 encoded message signature */ public static func getAccountMessageSignature(message: String) -> String? { - os_log("ApproovService: getAccountMessageSignature", type: .debug) + if loggingLevel >= .debug { + os_log("ApproovService: getAccountMessageSignature", type: .debug) + } return Approov.getMessageSignature(message) } @@ -553,7 +673,9 @@ public class ApproovService { * @return String of the base64 encoded message signature */ public static func getInstallMessageSignature(message: String) -> String? { - os_log("ApproovService: getInstallMessageSignature", type: .debug) + if loggingLevel >= .debug { + os_log("ApproovService: getInstallMessageSignature", type: .debug) + } return Approov.getInstallMessageSignature(message) } @@ -584,23 +706,13 @@ public class ApproovService { // try and fetch the secure string let approovResult = Approov.fetchSecureStringAndWait(key, newDef) - os_log("ApproovService: fetchSecureString: %@: %@", type: .info, type, Approov.string(from: approovResult.status)) - - // process the returned Approov status - if approovResult.status == ApproovTokenFetchStatus.rejected { - // if the request is rejected then we provide a special exception with additional information - throw ApproovError.rejectionError(message: "fetchSecureString: rejected", ARC: approovResult.arc, - rejectionReasons: approovResult.rejectionReasons) - } else if approovResult.status == ApproovTokenFetchStatus.noNetwork || - approovResult.status == ApproovTokenFetchStatus.poorNetwork || - approovResult.status == ApproovTokenFetchStatus.mitmDetected { - // we are unable to get the secure string due to network conditions so the request can - // be retried by the user later - throw ApproovError.networkingError(message: "fetchSecureString network error: " + Approov.string(from: approovResult.status)) - } else if ((approovResult.status != ApproovTokenFetchStatus.success) && (approovResult.status != ApproovTokenFetchStatus.unknownKey)) { - // we are unable to get the secure string due to a more permanent error - throw ApproovError.permanentError(message: "fetchSecureString: " + Approov.string(from: approovResult.status)) + if loggingLevel >= .info { + os_log("ApproovService: fetchSecureString: %@: %@", type: .info, type, Approov.string(from: approovResult.status)) } + + // process the returned Approov status using the mutator + let mutator = stateQueue.sync { return serviceMutator } + try mutator.handleFetchSecureStringResult(approovResult, operation: type, key: key) return approovResult.secureString } @@ -620,23 +732,13 @@ public class ApproovService { public static func fetchCustomJWT(payload: String) throws -> String? { // fetch the custom JWT let approovResult = Approov.fetchCustomJWTAndWait(payload) - os_log("ApproovService: fetchCustomJWT: %@", type: .info, Approov.string(from: approovResult.status)) - - // process the returned Approov status - if approovResult.status == ApproovTokenFetchStatus.rejected { - // if the request is rejected then we provide a special exception with additional information - throw ApproovError.rejectionError(message: "fetchCustomJWT: rejected", ARC: approovResult.arc, - rejectionReasons: approovResult.rejectionReasons) - } else if approovResult.status == ApproovTokenFetchStatus.noNetwork || - approovResult.status == ApproovTokenFetchStatus.poorNetwork || - approovResult.status == ApproovTokenFetchStatus.mitmDetected { - // we are unable to get the custom JWT due to network conditions so the request can - // be retried by the user later - throw ApproovError.networkingError(message: "fetchCustomJWT network error: " + Approov.string(from: approovResult.status)) - } else if (approovResult.status != ApproovTokenFetchStatus.success) { - // we are unable to get the custom JWT due to a more permanent error - throw ApproovError.permanentError(message: "fetchCustomJWT: " + Approov.string(from: approovResult.status)) + if loggingLevel >= .info { + os_log("ApproovService: fetchCustomJWT: %@", type: .info, Approov.string(from: approovResult.status)) } + + // process the returned Approov status using the mutator + let mutator = stateQueue.sync { return serviceMutator } + try mutator.handleFetchCustomJWTResult(approovResult) return approovResult.token } @@ -688,20 +790,50 @@ public class ApproovService { * @param request is the original request to be made * @return ApproovUpdateResponse providing an updated requests, plus an errors and status */ - static func updateRequestWithApproov(request: URLRequest) -> ApproovUpdateResponse { - // check if the SDK is not initialized or if the URL matches one of the exclusion regexs and just return if it does + @available(*, deprecated, renamed: "updateRequestWithApproov(_:)") + public static func updateRequestWithApproov(request: URLRequest) -> ApproovUpdateResponse { + return updateRequestWithApproov(request) + } + + /** + * Updates the request with Approov protection. + * + * @param request is the original request to be made + * @return ApproovUpdateResponse providing an updated requests, plus an errors and status + */ + public static func updateRequestWithApproov(_ request: URLRequest) -> ApproovUpdateResponse { var changes = ApproovRequestMutations() + + // fetch the mutator that modifies decision-making behavior + let mutator = stateQueue.sync { return serviceMutator } + + // check if the mutator wants to process this request + do { + if try !mutator.handleInterceptorShouldProcessRequest(request) { + return ApproovUpdateResponse(request: request, decision: .ShouldProceed, sdkMessage: "", error: nil) + } + } catch { + let nsError = error as NSError + return ApproovUpdateResponse(request: request, decision: .ShouldFail, sdkMessage: "", error: ApproovError.permanentError(message: nsError.localizedDescription)) + } + if let url = request.url { if !isInitialized { - os_log("ApproovService: not initialized, forwarding: %@", type: .info, url.absoluteString) + if loggingLevel >= .info { + os_log("ApproovService: not initialized, forwarding: %@", type: .info, url.absoluteString) + } return ApproovUpdateResponse(request: request, decision: .ShouldIgnore, sdkMessage: "", error: nil) } if isURLExcluded(url: url) { - os_log("ApproovService: excluded, forwarding: %@", type: .info, url.absoluteString) + if loggingLevel >= .info { + os_log("ApproovService: excluded, forwarding: %@", type: .info, url.absoluteString) + } return ApproovUpdateResponse(request: request, decision: .ShouldIgnore, sdkMessage: "", error: nil) } } else { - os_log("ApproovService: no url provided", type: .info) + if loggingLevel >= .info { + os_log("ApproovService: no url provided", type: .info) + } return ApproovUpdateResponse(request: request, decision: .ShouldIgnore, sdkMessage: "", error: nil) } @@ -723,72 +855,98 @@ public class ApproovService { // fetch an Approov token: request.url can not be nil here let approovResult = Approov.fetchTokenAndWait(request.url!.absoluteString) let hostname = hostnameFromURL(url: request.url!) - os_log("ApproovService: updateRequest %@: %@", type: .info, hostname, approovResult.loggableToken()) + if loggingLevel >= .info { + os_log("ApproovService: updateRequest %@: %@", type: .info, hostname, approovResult.loggableToken()) + } // log if a configuration update is received and call fetchConfig to clear the update state if approovResult.isConfigChanged { Approov.fetchConfig() - os_log("ApproovService: dynamic configuration update received") + if loggingLevel >= .info { + os_log("ApproovService: dynamic configuration update received") + } + } + + // handle the Approov token fetch response with the mutator + do { + if try !mutator.handleInterceptorFetchTokenResult(approovResult, url: request.url!.absoluteString) { + // Determine whether mutator aborted due to rejection, networking issue or config error by running the default logic + // we construct a response to return + var response = ApproovUpdateResponse(request: request, decision: .ShouldFail, sdkMessage: Approov.string(from: approovResult.status), error: nil) + switch approovResult.status { + case .noNetwork, .poorNetwork, .mitmDetected: + response.decision = .ShouldRetry + response.error = ApproovError.networkingError(message: response.sdkMessage) + case .noApproovService, .unknownURL, .unprotectedURL: + response.decision = .ShouldProceed + default: + response.error = ApproovError.permanentError(message: response.sdkMessage) + } + return response + } + } catch let mutatorError as ApproovError { + // Handle specific errors thrown by the mutator (e.g. throwing rejection error or networking error early) + switch mutatorError { + case .rejectionError: + return ApproovUpdateResponse(request: request, decision: .ShouldFail, sdkMessage: Approov.string(from: approovResult.status), error: mutatorError) + case .networkingError: + return ApproovUpdateResponse(request: request, decision: .ShouldRetry, sdkMessage: Approov.string(from: approovResult.status), error: mutatorError) + default: + return ApproovUpdateResponse(request: request, decision: .ShouldFail, sdkMessage: Approov.string(from: approovResult.status), error: mutatorError) + } + } catch { + return ApproovUpdateResponse(request: request, decision: .ShouldFail, sdkMessage: Approov.string(from: approovResult.status), error: ApproovError.permanentError(message: error.localizedDescription)) } - // handle the Approov token fetch response response.sdkMessage = Approov.string(from: approovResult.status) var hasChanges = false var setTokenHeaderKey: String? var setTokenHeaderValue: String? var setTraceIDHeaderKey: String? var setTraceIDHeaderValue: String? - // All paths through this switch statement must set response.decision - switch approovResult.status { - case ApproovTokenFetchStatus.success: - // go ahead and make the API call and add the Approov token header - response.decision = .ShouldProceed - let tokenHeader = stateQueue.sync { - return approovTokenHeader - } - let tokenPrefix = stateQueue.sync { - return approovTokenPrefix - } + + // All paths proceeding past the Mutator imply the request should continue + response.decision = .ShouldProceed + + let tokenHeader = stateQueue.sync { return approovTokenHeader } + let tokenPrefix = stateQueue.sync { return approovTokenPrefix } + let useStatus = stateQueue.sync { return useApproovStatusIfNoToken } + + if approovResult.status == .success { + // Success Path hasChanges = true setTokenHeaderKey = tokenHeader - setTokenHeaderValue = tokenPrefix + approovResult.token + + if useStatus && approovResult.token.isEmpty { + setTokenHeaderValue = tokenPrefix + Approov.string(from: approovResult.status) + } else { + setTokenHeaderValue = tokenPrefix + approovResult.token + } let traceID = approovResult.traceID if let traceHeader = stateQueue.sync(execute: { approovTraceIDHeader }), - !traceHeader.isEmpty, - !traceID.isEmpty { + !traceHeader.isEmpty, !traceID.isEmpty { hasChanges = true setTraceIDHeaderKey = traceHeader setTraceIDHeaderValue = traceID } - case ApproovTokenFetchStatus.noNetwork, - ApproovTokenFetchStatus.poorNetwork, - ApproovTokenFetchStatus.mitmDetected: - // we are unable to get the Approov token due to network conditions - if !proceedOnNetworkFail { - // unless required to proceed; the request can be retried by the user later - response.decision = .ShouldRetry - response.error = ApproovError.networkingError(message: response.sdkMessage) - return response + } else if approovResult.status != .noApproovService, + approovResult.status != .unknownURL, + approovResult.status != .unprotectedURL { + // We are proceeding (allowed by mutator) with a failure status. + // Add the status string to the Approov token header if + // useApproovStatusIfNoToken is set, so callers can observe it. + if useStatus { + hasChanges = true + setTokenHeaderKey = tokenHeader + setTokenHeaderValue = tokenPrefix + Approov.string(from: approovResult.status) } - // otherwise, proceed with the request but without the Approov token header - response.decision = .ShouldProceed - case ApproovTokenFetchStatus.unprotectedURL, - ApproovTokenFetchStatus.unknownURL, - ApproovTokenFetchStatus.noApproovService: - // we proceed but do NOT add the Approov token header to the request headers - response.decision = .ShouldProceed - default: - // we have a more permanent error condition - response.decision = .ShouldFail - response.error = ApproovError.permanentError(message: response.sdkMessage) - return response } // we only continue additional processing if we had a valid status from Approov, to prevent additional delays // by trying to fetch from Approov again and this also protects against header substitutions in domains not // protected by Approov and therefore are potentially subject to a MitM. - if (approovResult.status != .success) && (approovResult.status != .unprotectedURL) { + if approovResult.status != .success { return response } @@ -799,49 +957,48 @@ public class ApproovService { let subsHeadersCopy = stateQueue.sync { return substitutionHeaders } + + // apply any header substitutions using the mutator policy for (header, prefix) in subsHeadersCopy { - if let value = requestHeaders[header] { - // check if the request contains the header we want to replace - if ((value.hasPrefix(prefix)) && (value.count > prefix.count)) { - // fetch any secure string keyed from the current value, without any prefix - let index = prefix.index(prefix.startIndex, offsetBy: prefix.count) - let approovResults = Approov.fetchSecureStringAndWait(String(value.suffix(from:index)), nil) - os_log("ApproovService: Substituting header: %@, %@", type: .info, header, Approov.string(from: approovResults.status)) - - // process the result of the token fetch operation + if let headerValue = requestHeaders[header], headerValue.hasPrefix(prefix), headerValue.count > prefix.count { + let key = String(headerValue.dropFirst(prefix.count)) + let approovResults = Approov.fetchSecureStringAndWait(key, nil) + + // we check if mutator allows processing substitution + var fetchString = false + do { + if try mutator.handleInterceptorHeaderSubstitutionResult(approovResults, header: header) { + fetchString = true + } + } catch let mutatorError as ApproovError { + switch mutatorError { + case .networkingError: + response.decision = .ShouldRetry + response.error = mutatorError + default: + response.decision = .ShouldFail + response.error = mutatorError + } + return response + } catch { + response.decision = .ShouldFail + response.error = ApproovError.permanentError(message: error.localizedDescription) + return response + } + + if fetchString { + if loggingLevel >= .info { + os_log("ApproovService: Substituting header: %@, %@", type: .info, header, Approov.string(from: approovResults.status)) + } if approovResults.status == ApproovTokenFetchStatus.success { - // we add the modified header to the new copy of request if let secureStringResult = approovResults.secureString { hasChanges = true; setSubstitutionHeaders[header] = prefix + secureStringResult } else { - // secure string is nil response.decision = .ShouldFail response.error = ApproovError.permanentError(message: "Header substitution: key lookup error") return response } - } else if approovResults.status == ApproovTokenFetchStatus.rejected { - // if the request is rejected then we provide a special exception with additional information - response.decision = .ShouldFail - response.error = ApproovError.rejectionError(message: "Header substitution: rejected", - ARC: approovResults.arc, - rejectionReasons: approovResults.rejectionReasons) - return response - } else if approovResults.status == ApproovTokenFetchStatus.noNetwork || - approovResults.status == ApproovTokenFetchStatus.poorNetwork || - approovResults.status == ApproovTokenFetchStatus.mitmDetected { - // we are unable to get the secure string due to network conditions so the request can - // be retried by the user later - if !proceedOnNetworkFail { - response.decision = .ShouldRetry - response.error = ApproovError.networkingError(message: "Header substitution: network issue, retry needed") - return response - } - } else if approovResults.status != ApproovTokenFetchStatus.unknownKey { - // we have failed to get a secure string with a more serious permanent error - response.decision = .ShouldFail - response.error = ApproovError.permanentError(message: "Header substitution: " + Approov.string(from: approovResults.status)) - return response } } } @@ -871,12 +1028,33 @@ public class ApproovService { if let substringRange = Range(matchRange, in: updateURLString) { let queryValue = String(updateURLString[substringRange]) let approovResults = Approov.fetchSecureStringAndWait(String(queryValue), nil) - os_log("ApproovService: Substituting query parameter: %@, %@", entry, - Approov.string(from: approovResults.status)) + + if loggingLevel >= .info { + os_log("ApproovService: Attempting query parameter substitution: %@, %@", entry, + Approov.string(from: approovResults.status)) + } + + var allowSub = false + do { + allowSub = try mutator.handleInterceptorQueryParamSubstitutionResult(approovResults, queryKey: entry) + } catch let mutatorError as ApproovError { + switch mutatorError { + case .networkingError: + response.decision = .ShouldRetry + response.error = mutatorError + default: + response.decision = .ShouldFail + response.error = mutatorError + } + return response + } catch { + response.decision = .ShouldFail + response.error = ApproovError.permanentError(message: error.localizedDescription) + return response + } // process the result of the secure string fetch operation - switch approovResults.status { - case .success: + if allowSub && approovResults.status == .success { // perform a query substitution if let secureStringResult = approovResults.secureString { hasChanges = true @@ -890,37 +1068,6 @@ public class ApproovService { return response } } - case .rejected: - // if the request is rejected then we provide a special exception with additional information - response.decision = .ShouldFail - response.error = ApproovError.rejectionError( - message: "Query parameter substitution for \(entry) rejected", - ARC: approovResults.arc, - rejectionReasons: approovResults.rejectionReasons - ) - return response - case .noNetwork, - .poorNetwork, - .mitmDetected: - // we are unable to get the secure string due to network conditions so the request can - // be retried by the user later - if !proceedOnNetworkFail { - response.decision = .ShouldRetry - response.error = ApproovError.networkingError(message: "Query parameter substitution for " + - "\(entry): network issue, retry needed") - return response - } - case .unknownKey: - // do not modify the URL - break - default: - // we have failed to get a secure string with a more permanent error - response.decision = .ShouldFail - response.error = ApproovError.permanentError( - message: "Query parameter substitution for \(entry): " + - Approov.string(from: approovResults.status) - ) - return response } } } @@ -955,14 +1102,13 @@ public class ApproovService { } // call the processed request callback - if let interceptorExtensions = ApproovService.interceptorExtensions { - do { - response.request = try interceptorExtensions.processedRequest(response.request, changes: changes) - } catch let error { - response.decision = .ShouldFail - response.error = ApproovError.permanentError( - message: "Interceptor extension for processed request error: \(error.localizedDescription)") - } + do { + response.request = try mutator.handleInterceptorProcessedRequest(response.request, changes: changes) + } catch let error { + response.decision = .ShouldFail + response.error = ApproovError.permanentError( + message: "Interceptor processed request error: \(error.localizedDescription)") + return response } return response diff --git a/Sources/ApproovSession/ApproovServiceMutator.swift b/Sources/ApproovSession/ApproovServiceMutator.swift new file mode 100644 index 0000000..71ec2bd --- /dev/null +++ b/Sources/ApproovSession/ApproovServiceMutator.swift @@ -0,0 +1,271 @@ +// MIT License +// +// Copyright (c) 2016-present, Approov Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH +// THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import Approov +import Foundation + +/** + * ApproovServiceMutator provides an interface for modifying the behavior of + * the ApproovService class by overriding the default implementations of the + * defined callbacks. Opportunities to modify behavior are offered at key + * points in the service and attestation flows. + * + * The protocol provides default implementations for all methods, so + * implementing classes can choose to override only the methods they are + * interested in. The default implementations provide standard behavior + * that is suitable for most use cases and provides backwards compatibility + * with previous versions of this Approov service layer. + */ +public protocol ApproovServiceMutator { + /** + * Decides how to handle the token fetch result from an + * ApproovService.precheck() operation. + */ + func handlePrecheckResult(_ approovResults: ApproovTokenFetchResult) throws + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchToken() operation. + */ + func handleFetchTokenResult(_ approovResults: ApproovTokenFetchResult) throws + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchSecureString() operation. + */ + func handleFetchSecureStringResult(_ approovResults: ApproovTokenFetchResult, + operation: String, + key: String) throws + + /** + * Decides how to handle the token fetch result from an + * ApproovService.fetchCustomJWT() operation. + */ + func handleFetchCustomJWTResult(_ approovResults: ApproovTokenFetchResult) throws + + /** + * Decides whether a request should be processed in the interceptor or not. + * Called at the start of the ApproovService interceptor processing. + */ + func handleInterceptorShouldProcessRequest(_ request: URLRequest) throws -> Bool + + /** + * Decides how to handle the token fetch result from a call to + * Approov.fetchTokenAndWait() from within the interceptor. + */ + func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult, + url: String) throws -> Bool + + /** + * Decides how to handle the token fetch result while substituting headers + * from within the interceptor. + */ + func handleInterceptorHeaderSubstitutionResult(_ approovResults: ApproovTokenFetchResult, + header: String) throws -> Bool + + /** + * Decides how to handle the token fetch result while substituting query params + * from within the interceptor. + */ + func handleInterceptorQueryParamSubstitutionResult(_ approovResults: ApproovTokenFetchResult, + queryKey: String) throws -> Bool + + /** + * Called after Approov has processed a network request, allowing further + * modifications. + */ + func handleInterceptorProcessedRequest(_ request: URLRequest, + changes: ApproovRequestMutations) throws -> URLRequest + + /** + * Decides whether certificate pinning should be applied to a request or not. + * Called at the start of the ApproovService pinning processing. + */ + func handlePinningShouldProcessRequest(_ request: URLRequest) -> Bool +} + +public extension ApproovServiceMutator { + func handlePrecheckResult(_ approovResults: ApproovTokenFetchResult) throws { + let status = approovResults.status + switch status { + case .rejected: + throw ApproovError.rejectionError(message: "precheck: rejected", + ARC: approovResults.arc, + rejectionReasons: approovResults.rejectionReasons) + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "precheck network error: " + Approov.string(from: status)) + case .success, + .unknownKey: + return + default: + throw ApproovError.permanentError(message: "precheck: " + Approov.string(from: status)) + } + } + + func handleFetchTokenResult(_ approovResults: ApproovTokenFetchResult) throws { + let status = approovResults.status + switch status { + case .success: + return + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "fetchToken network error: " + Approov.string(from: status)) + default: + throw ApproovError.permanentError(message: "fetchToken: " + Approov.string(from: status)) + } + } + + func handleFetchSecureStringResult(_ approovResults: ApproovTokenFetchResult, + operation: String, + key: String) throws { + let status = approovResults.status + switch status { + case .rejected: + throw ApproovError.rejectionError(message: "fetchSecureString \(operation) for \(key): rejected", + ARC: approovResults.arc, + rejectionReasons: approovResults.rejectionReasons) + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "fetchSecureString \(operation) for \(key): " + + Approov.string(from: status)) + case .success, + .unknownKey: + return + default: + throw ApproovError.permanentError(message: "fetchSecureString \(operation) for \(key): " + + Approov.string(from: status)) + } + } + + func handleFetchCustomJWTResult(_ approovResults: ApproovTokenFetchResult) throws { + let status = approovResults.status + switch status { + case .rejected: + throw ApproovError.rejectionError(message: "fetchCustomJWT: rejected", + ARC: approovResults.arc, + rejectionReasons: approovResults.rejectionReasons) + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "fetchCustomJWT network error: " + Approov.string(from: status)) + case .success: + return + default: + throw ApproovError.permanentError(message: "fetchCustomJWT: " + Approov.string(from: status)) + } + } + + func handleInterceptorShouldProcessRequest(_ request: URLRequest) throws -> Bool { + guard let url = request.url else { + throw ApproovError.permanentError(message: "handleInterceptorShouldProcessRequest received a request with no URL") + } + let urlString = url.absoluteString + let urlStringRange = NSRange(urlString.startIndex.. Bool { + let status = approovResults.status + switch status { + case .success: + return true + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "Approov token fetch for \(url): " + Approov.string(from: status)) + case .noApproovService, + .unknownURL, + .unprotectedURL: + return false + default: + throw ApproovError.permanentError(message: "Approov token fetch for \(url): " + + Approov.string(from: status)) + } + } + + func handleInterceptorHeaderSubstitutionResult(_ approovResults: ApproovTokenFetchResult, + header: String) throws -> Bool { + let status = approovResults.status + switch status { + case .success: + return true + case .rejected: + throw ApproovError.rejectionError(message: "Header substitution for \(header): rejected", + ARC: approovResults.arc, + rejectionReasons: approovResults.rejectionReasons) + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "Header substitution for \(header): " + Approov.string(from: status)) + case .unknownKey: + return false + default: + throw ApproovError.permanentError(message: "Header substitution for \(header): " + + Approov.string(from: status)) + } + } + + func handleInterceptorQueryParamSubstitutionResult(_ approovResults: ApproovTokenFetchResult, + queryKey: String) throws -> Bool { + let status = approovResults.status + switch status { + case .success: + return true + case .rejected: + throw ApproovError.rejectionError(message: "Query parameter substitution for \(queryKey): rejected", + ARC: approovResults.arc, + rejectionReasons: approovResults.rejectionReasons) + case .noNetwork, + .poorNetwork, + .mitmDetected: + throw ApproovError.networkingError(message: "Query parameter substitution for \(queryKey): " + Approov.string(from: status)) + case .unknownKey: + return false + default: + throw ApproovError.permanentError(message: "Query parameter substitution for \(queryKey): " + + Approov.string(from: status)) + } + } + + func handleInterceptorProcessedRequest(_ request: URLRequest, + changes: ApproovRequestMutations) throws -> URLRequest { + return request + } + + func handlePinningShouldProcessRequest(_ request: URLRequest) -> Bool { + return true + } +} + +public struct ApproovServiceMutatorDefault: ApproovServiceMutator, CustomStringConvertible { + public static let shared = ApproovServiceMutatorDefault() + + public var description: String { + return "ApproovServiceMutator.DEFAULT" + } + + private init() {} +} diff --git a/Sources/ApproovSession/ApproovSession.swift b/Sources/ApproovSession/ApproovSession.swift index 8d4894d..8cf666b 100644 --- a/Sources/ApproovSession/ApproovSession.swift +++ b/Sources/ApproovSession/ApproovSession.swift @@ -32,11 +32,11 @@ private class ApproovInterceptor: RequestInterceptor { * https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests-with-requestinterceptor */ public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { - let ApproovUpdateResponse = ApproovService.updateRequestWithApproov(request: urlRequest) - if (ApproovUpdateResponse.decision == .ShouldProceed) || (ApproovUpdateResponse.decision == .ShouldIgnore) { - completion(.success(ApproovUpdateResponse.request)) + let approovUpdateResponse = ApproovService.updateRequestWithApproov(urlRequest) + if (approovUpdateResponse.decision == .ShouldProceed) || (approovUpdateResponse.decision == .ShouldIgnore) { + completion(.success(approovUpdateResponse.request)) } else { - completion(.failure(ApproovUpdateResponse.error!)) + completion(.failure(approovUpdateResponse.error!)) } } } diff --git a/Sources/ApproovSession/ApproovTrustManager.swift b/Sources/ApproovSession/ApproovTrustManager.swift index ca08131..6c3d76b 100644 --- a/Sources/ApproovSession/ApproovTrustManager.swift +++ b/Sources/ApproovSession/ApproovTrustManager.swift @@ -154,7 +154,9 @@ public final class ApproovTrustEvaluator: ServerTrustEvaluating { // get the dynamic pins from Approov guard let approovPins = Approov.getPins("public-key-sha256") else { // just return if there are no Approov pins (this can happen if Approov is not initialized) - os_log("ApproovService: pin verification no Approov pins") + if ApproovService.loggingLevel >= .info { + os_log("ApproovService: pin verification no Approov pins") + } return } @@ -167,7 +169,9 @@ public final class ApproovTrustEvaluator: ServerTrustEvaluating { // there are no pins set for the host so use managed trust roots if available if approovPins["*"] == nil { // there are no managed trust roots so the host is truly unpinned - os_log("ApproovService: pin verification %@ no pins", host) + if ApproovService.loggingLevel >= .info { + os_log("ApproovService: pin verification %@ no pins", host) + } return true } else { // use the managed trust roots for pinning @@ -183,24 +187,35 @@ public final class ApproovTrustEvaluator: ServerTrustEvaluating { let publicKeyHashBase64 = String(data:publicKeyHash.base64EncodedData(), encoding: .utf8) for pin in pinsForHost { if publicKeyHashBase64 == pin { - os_log("ApproovService: matched pin %@ for %@ from %d pins", pin, host, pinsForHost.count) + if ApproovService.loggingLevel >= .debug { + os_log("ApproovService: matched pin %@ for %@ from %d pins", pin, host, pinsForHost.count) + } return true } } } catch let error { // if there is an exception (typically because the certificate type isn't supported to get the SPKI header) then // we simply log this on the basis that it might not being pinned to anyway - os_log("ApproovService: skipping pinning for certificate: %@", error.localizedDescription) + if ApproovService.loggingLevel >= .info { + os_log("ApproovService: skipping pinning for certificate: %@", error.localizedDescription) + } } } // we didn't find any matching pins - os_log("ApproovService: pin verification failed for %@ with no match for %d pins", host, pinsForHost.count) + if ApproovService.loggingLevel >= .error { + os_log("ApproovService: pin verification failed for %@ with no match for %d pins", + type: .error, + host, + pinsForHost.count) + } return false } else { // host is not included in the Approov pins and therefore not pinned - os_log("ApproovService: pin verification %@ unpinned", host) + if ApproovService.loggingLevel >= .info { + os_log("ApproovService: pin verification %@ unpinned", host) + } return true } }() diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..918de16 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,315 @@ +# Usage + +This document describes the features and functionality of the Approov Service for Alamofire. It provides details on how to interact with the service layer and customize its behavior to suit your application's needs, specifically through the `ApproovServiceMutator`. For a basic integration example, please refer to the [Quickstart guide](https://github.com/approov/quickstart-ios-swift-alamofire). + +# Approov Service Mutator + +The `ApproovServiceMutator` allows you to customize the behavior of the Approov Alamofire layer at key points in the request lifecycle. You can override specific methods to tailor the handling of attestations and requests while retaining the default behavior for other cases. + +## Why use a mutator + +- Centralize app-specific policy without forking the service layer. +- Add telemetry on rejections or network failures. +- Skip Approov processing for health checks or local endpoints. +- Customize pinning decisions per request. +- Adjust behavior when token or secure string fetches fail. + +## Default Behavior + +By default, the `ApproovService` processes requests based on the attestation status. It relies on the underlying SDK to provide a proof of attestation, which is a cryptographically signed JWT token. Requesting this attestation typically returns the token immediately; however, a network connection to the Approov cloud is required upon app launch or when the token is nearing expiration. Note that the SDK only knows if an attestation token has been obtained; it cannot determine if the token is valid (validity is checked by your backend). The default behavior is described in more detail in the official documentation section [Approov Token Fetch Results](https://ext.approov.io/docs/latest/approov-direct-sdk-integration/#fetch-status-handling) and is summarized in the table below: + +| Approov Fetch Status | Action | Result | +| :--- | :--- | :--- | +| **Success** | Proceed | The request acts as expected and is sent with the `Approov-Token`. | +| **No Network / Poor Network** | Throw Exception | An `ApproovError.networkingError` is thrown. The request is marked as `.ShouldRetry`. | +| **Rejection** | Throw Exception | An `ApproovError.rejectionError` is thrown. The request is marked as `.ShouldFail`. | +| **No Approov Service / Unknown URL** | Proceed | The request is sent **without** an `Approov-Token`. | + +## Multiple Service Layers + +It is possible to use more than one Approov service layer in the same app, for example the URLSession and Alamofire packages together. The underlying Approov SDK can only be initialized once, so if another service layer later calls `ApproovService.initialize(...)` with the same configuration, the duplicate SDK initialization is detected and ignored. + +In that case you may see a log entry similar to: + +```text +ApproovService: Ignoring initialization error in Approov SDK: The operation couldn’t be completed. (Foundation._GenericObjCError error 0.) +``` + +This log is informational only. Execution continues and this condition is not surfaced as an exception. If a later initialization attempts to use a different configuration, that is still treated as a real configuration error. + +## Customizing Request Handling with Mutators + +You may want to modify this behavior to suit specific app requirements. A common use case is handling `NO_APPROOV_SERVICE` statuses. + +### Prevent Access Without a Token (e.g. NO_APPROOV_SERVICE) + +The standard behavior for statuses like `NO_APPROOV_SERVICE` is to proceed with the request without adding an Approov token. This might occur, for example, if a device cannot connect to the Approov cloud due to a restricted network environment. You may wish to prevent this behavior to ensure that *only* requests with valid proof of attestation reach your backend API, allowing you to explicitly handle this case within your application. + +You can use a mutator to enforce this policy by throwing an error or returning `false` for such statuses. + +### Example: Enforce Token Presence + +Override `handleInterceptorFetchTokenResult` to check for `noApproovService` and prevent the request to your API from continuing; instead log the event. Since `NO_APPROOV_SERVICE` implies the SDK cannot reach the Approov servers, this could be a transient issue (e.g., no DNS server available) or a permanent configuration/network restriction. You might choose to retry the request once to handle transient errors, or if the issue persists, inform the user of a network issue and suggest checking their connection or changing networks. + +```swift +import ApproovAFSession +import Approov + +final class EnforceTokenMutator: ApproovServiceMutator { + func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult, url: String) throws -> Bool { + // If the service is not available (NO_APPROOV_SERVICE), do not proceed. + // This could be transient (e.g. no DNS) so we throw a networking error to trigger a retry. + if approovResults.status == .noApproovService { + throw ApproovError.networkingError(message: "Network issue. Will attempt connection again.") + } + + // For all other statuses, use the default behavior. + return try ApproovServiceMutatorDefault.shared.handleInterceptorFetchTokenResult(approovResults, url: url) + } +} +``` + +### Allow Access Without Token (Optional) + +Conversely, if the device could not obtain proof of attestation, for example because of a `.mitmDetected` response from the SDK, the default behavior is to cancel the request to your API. However, you might prefer to let the request attempt the connection to your backend without the Approov Token to allow for server-side handling (e.g., returning a custom 401/403) or specialized honeypot routing. + +To implement this, check for `.mitmDetected` and return `true`, which proceeds without the token instead of throwing an error. + +```swift +import ApproovAFSession +import Approov + +final class AllowOfflineMutator: ApproovServiceMutator { + func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult, url: String) throws -> Bool { + // If MITM is detected, we still proceed without a token to let the backend handle it + if approovResults.status == .mitmDetected { + return true + } + + // For all other statuses, use the default behavior + return try ApproovServiceMutatorDefault.shared.handleInterceptorFetchTokenResult(approovResults, url: url) + } +} +``` + + +### Add custom headers using a mutator + +You can override `handleInterceptorProcessedRequest` to add additional headers or modify the request after Approov has processed it. This is useful for adding app metadata or other diagnostics. + +```swift +final class MyMutator: ApproovServiceMutator { + // If you are composing with another mutator (like a signer), initialize it here. + // Otherwise, you can use ApproovServiceMutatorDefault.shared. + let signer: ApproovServiceMutator = ApproovServiceMutatorDefault.shared + + /// Called after Approov has already mutated the request (token, substitutions, signing). + /// + /// Use this to add *additional* headers or rewrite the request further. This is also + /// where message signing should remain in place if you use a signer mutator. + func handleInterceptorProcessedRequest(_ request: URLRequest, + changes: ApproovRequestMutations) throws -> URLRequest { + var req = try signer.handleInterceptorProcessedRequest(request, changes: changes) + // Example: attach app metadata for backend diagnostics or routing. + req.setValue("ios", forHTTPHeaderField: "Client-Platform") + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + req.setValue(version, forHTTPHeaderField: "App-Version") + } + return req + } +} +``` + +## How to use a custom mutator in your application + +Create a mutator, then install it once during app startup (for example in your AppDelegate or app initialization path). + +```swift +import ApproovAFSession +import Approov + +final class MyMutator: ApproovServiceMutator { + // Override only the hooks you need. +} +ApproovService.setServiceMutator(MyMutator()) // Install custom implementation or pass nil to revert to default behaviour +``` + + + +## Message signing + +It is possible to sign HTTP requests using Approov to ensure message integrity and authenticity. There are two types of message signing available: + +1. [Installation Message Signing](https://ext.approov.io/docs/latest/approov-usage-documentation/#installation-message-signing): Uses an installation-specific key (held in the device's Secure Enclave/TEE) to sign requests. This provides strong non-repudiation as the signing key never leaves the device and is unique to that specific installation. +2. [Account Message Signing](https://ext.approov.io/docs/latest/approov-usage-documentation/#account-message-signing): Uses a shared account-specific secret key (HMAC-SHA256) to sign requests. This key is delivered to the SDK only upon successful attestation. + +**Advantages of Message Signing:** +* **Integrity:** Ensures that the request parameters (headers, body, URL) have not been tampered with during transit. +* **Authenticity:** Proves that the request originated from a genuine, attested application instance. + +Message signing is not enabled unless you opt in. By default, the `ApproovService` uses the class `ApproovServiceMutatorDefault`, which does no message signing. Even if you install `ApproovDefaultMessageSigning`, a signature is only added when: + +- The request already has an `Approov-Token` header (i.e., Approov processing ran). +- A `SignatureParametersFactory` is configured (default or host-specific). + +### Enable with default settings + +```swift +let factory = ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory() +let signer = ApproovDefaultMessageSigning().setDefaultFactory(factory) +ApproovService.setServiceMutator(signer) +``` + +If you have already customized the mutator, you can add message signing to it like so: + +```swift +let signer = ApproovDefaultMessageSigning() + .setDefaultFactory(ApproovDefaultMessageSigning.generateDefaultSignatureParametersFactory()) +ApproovService.setServiceMutator(MyMutator(signer: signer)) +``` + +### Customize behavior + +```swift +let factory = SignatureParametersFactory() + .setUseAccountMessageSigning() // or setUseInstallMessageSigning() + .setAddCreated(true) + .setExpiresLifetime(60) + +let signer = ApproovDefaultMessageSigning() + .setDefaultFactory(factory) + .putHostFactory(hostName: "api.example.com", factory: factory) + +ApproovService.setServiceMutator(signer) +``` + +To disable signing, remove the signer (`setServiceMutator(nil)`) or return `nil` +from your factory for hosts you want to skip. If you have custom mutator logic, +call the signer from `handleInterceptorProcessedRequest` (see example below). + +## Token Binding + +[Token Binding](https://ext.approov.io/docs/latest/approov-usage-documentation/#token-binding) allows you to bind the Approov token to a specific piece of data, such as an OAuth token or a user session identifier. This adds an extra layer of security by ensuring that the Approov token can only be used in conjunction with the bound data. The `ApproovService` calculates a hash of the binding data locally and includes this hash in the Approov token claims. It is important to note that the actual binding data is never sent to the Approov cloud service; only the hash is transmitted. + +To set up token binding, you specify a header name. The value of this header in your requests will be used for the binding. + +### Example: Bind to Authorization Header + +```swift +// Bind the Approov token to the Authorization header (e.g., for OAuth) +ApproovService.setBindingHeader(header: "Authorization") +``` + +If the value of the binding header changes (e.g., the user logs in and gets a new OAuth token), the SDK automatically invalidates the current Approov token and fetches a new one with the updated binding on the next request. + +## Use Approov Status as Token + +In some cases, you might want to send the Approov fetch status (e.g., `NO_NETWORK`, `MITM_DETECTED`) to your backend when an actual token cannot be obtained. This allows your backend to distinguish between different failure reasons even when the `Approov-Token` would otherwise be empty or missing. + +To enable this feature: + +```swift +ApproovService.setUseApproovStatusIfNoToken(shouldUse: true) +``` + +When enabled, if the Approov token fetch fails or returns an empty token, the `Approov-Token` header will be populated with the status string (with the configured prefix) instead of being left empty. + +## Logging + +You can customize the log level emitted by the `ApproovService` using the `ApproovLogLevel` enum. This controls the verbosity of unified logging (`os_log`) output generated internally by the package. + +The available log levels are: +* `.off`: Disables all logging from the `ApproovService` package. +* `.error`: Only logs critical errors (e.g., initialization failures, missing pins). +* `.warning`: Logs warnings and errors (e.g., duplicated initializations with identical configurations). +* `.info` (Default): Logs informative events, configuration receipts, and the token states being set. +* `.debug`: Logs highly verbose tracing information for every request, initialization step, and token fetch. Use only for in-depth debugging. + +To configure the log level: + +```swift +ApproovService.setLoggingLevel(.debug) +``` + +## Real-world examples + +### Policy-driven mutator (host scoping, offline fallback, message signing, pinning) + +This example implementation demonstrates how to customize the `ApproovServiceMutator` to apply different options to API requests based on the hostname. + +```swift +import ApproovAFSession + +final class CustomLogic: ApproovServiceMutator { + private let signer: ApproovServiceMutator + private let protectedHosts: Set // Hosts that require an Approov token + private let allowOfflineForHosts: Set // Hosts that allow requests without an Approov token + private let skipPinningHosts: Set // Hosts that skip pinning + + init( + signer: ApproovServiceMutator = ApproovDefaultMessageSigning(), + protectedHosts: Set = ["api.example.com"], + allowOfflineForHosts: Set = ["status.example.com"], + skipPinningHosts: Set = ["metrics.example.com"] + ) { + self.signer = signer + self.protectedHosts = protectedHosts + self.allowOfflineForHosts = allowOfflineForHosts + self.skipPinningHosts = skipPinningHosts + } + + func handleInterceptorShouldProcessRequest(_ request: URLRequest) throws -> Bool { + guard let host = request.url?.host, protectedHosts.contains(host) else { return false } + return try ApproovServiceMutatorDefault.shared.handleInterceptorShouldProcessRequest(request) + } + + func handleInterceptorFetchTokenResult(_ approovResults: ApproovTokenFetchResult, + url: String) throws -> Bool { + if approovResults.status == .noNetwork || approovResults.status == .poorNetwork, + let host = URL(string: url)?.host, allowOfflineForHosts.contains(host) { + return false + } + return try ApproovServiceMutatorDefault.shared + .handleInterceptorFetchTokenResult(approovResults, url: url) + } + + func handleInterceptorProcessedRequest(_ request: URLRequest, + changes: ApproovRequestMutations) throws -> URLRequest { + var req = try signer.handleInterceptorProcessedRequest(request, changes: changes) + req.setValue("ios", forHTTPHeaderField: "X-Client-Platform") + return req + } + + func handlePinningShouldProcessRequest(_ request: URLRequest) -> Bool { + guard let host = request.url?.host else { return true } + return !skipPinningHosts.contains(host) + } +} +``` + +### Log rejections with ARC + device ID to your telemetry + +An important part of your security strategy is to monitor and analyze rejections. Ideally, the server response would be customized to include the ARC and device ID in the response body or headers. However, if this is not possible, you can obtain these values from the `ApproovService` and log them to your telemetry directly from your application code. + +This example shows how to log rejections with the ARC and device ID. It assumes you are using a custom `ApproovServiceMutator` that prevents requests from proceeding without an Approov token. If this is not the case, and a request is made in poor network conditions, there is a small chance that `getLastARC()` will be executed just as the network interface becomes available. This would provide an ARC even though the original request timed out without one. The following code is a simple example of how to implement this logging: + +```swift + if let httpResponse = response.response { + let code = httpResponse.statusCode + if code == 200 { + // Process request + } else { + // Log rejection: ARC + device ID can be added for correlating a particular request to the failure reason + let arc = ApproovService.getLastARC() // We are certain we have an ARC code because our custom ApproovServiceMutator prevents requests without an Approov token to proceed. If this is not the case, we will not have an ARC code and should SKIP this line of code. + let deviceID = ApproovService.getDeviceID() + // Log rejection + myLogger.log("Request rejected with ARC: \(arc) and device ID: \(deviceID); response code: \(code)") + } + } +``` + +## Tips + +- Keep mutator logic fast and side-effect safe. These hooks run on the request path. +- Use `ApproovServiceMutatorDefault.shared` to preserve the existing behavior and layer your changes on top. +- If you override multiple hooks, keep them focused (one concern per hook) for easier testing and maintenance.