@dvai-bridge/ios 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DVAIBridge.podspec +120 -0
- package/LICENSE +51 -0
- package/Package.swift +104 -0
- package/README.md +199 -0
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
- package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
- package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
- package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
- package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
- package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
- package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
- package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
- package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
- package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
- package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
- package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
- package/package.json +19 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runtime audience + platform + dev-mode detection for the iOS SDK.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `packages/dvai-bridge-core/src/license/audience.ts`. The
|
|
5
|
+
* semantics are identical; only the platform APIs differ.
|
|
6
|
+
*
|
|
7
|
+
* - audience = `Bundle.main.bundleIdentifier` (may be `nil` in some
|
|
8
|
+
* unit-test hosts; null is treated like the JS side does
|
|
9
|
+
* — only `"*"` aud entries match)
|
|
10
|
+
* - platform = always `.ios` from this SDK (the Capacitor consumers
|
|
11
|
+
* use the Capacitor SDK, which detects `.capacitor`
|
|
12
|
+
* from JS-side; this iOS SDK is for native iOS apps)
|
|
13
|
+
* - dev mode = DEBUG build OR simulator OR DVAI_FORCE_DEV=1
|
|
14
|
+
* (DVAI_FORCE_PROD=1 overrides DEBUG / simulator)
|
|
15
|
+
*/
|
|
16
|
+
import Foundation
|
|
17
|
+
|
|
18
|
+
/// Detect the current SDK platform identifier. Always `.ios` on iOS —
|
|
19
|
+
/// Capacitor consumers use a different SDK (the Capacitor plugin's
|
|
20
|
+
/// JS-side validator detects `capacitor`). React Native consumers
|
|
21
|
+
/// likewise use a different SDK.
|
|
22
|
+
public func detectPlatform() -> DvaiPlatform {
|
|
23
|
+
return .ios
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Read an environment variable via C's `getenv` so we observe live
|
|
27
|
+
/// updates (`setenv` in tests). `ProcessInfo.environment` caches its
|
|
28
|
+
/// dict on Apple's Foundation, which makes per-test env mutation
|
|
29
|
+
/// unreliable.
|
|
30
|
+
internal func envVar(_ name: String) -> String? {
|
|
31
|
+
guard let cstr = getenv(name) else { return nil }
|
|
32
|
+
let value = String(cString: cstr)
|
|
33
|
+
return value.isEmpty ? nil : value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Detect the current audience string the license must bind. On iOS
|
|
37
|
+
/// this is the running app's bundle identifier (e.g. `"com.acme.app"`).
|
|
38
|
+
///
|
|
39
|
+
/// Returns `nil` only when no bundle identifier is available — uncommon
|
|
40
|
+
/// in production, but possible in some SwiftPM test hosts. Null is
|
|
41
|
+
/// handled by the validator: it matches only the `"*"` aud entry.
|
|
42
|
+
///
|
|
43
|
+
/// An explicit `DVAI_AUDIENCE` environment variable overrides the bundle
|
|
44
|
+
/// id (matches the JS-side override; mostly useful for tests).
|
|
45
|
+
public func detectAudience() -> String? {
|
|
46
|
+
if let override = envVar("DVAI_AUDIENCE") {
|
|
47
|
+
return override
|
|
48
|
+
}
|
|
49
|
+
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
|
|
50
|
+
return bundleId
|
|
51
|
+
}
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Detect whether the SDK is running in a developer environment where
|
|
56
|
+
/// license enforcement should be bypassed. The bypass list is
|
|
57
|
+
/// intentionally generous: blocking a developer mid-Xcode-run with a
|
|
58
|
+
/// license-not-found error would be hostile.
|
|
59
|
+
///
|
|
60
|
+
/// Order (first match wins):
|
|
61
|
+
/// 1. `DVAI_FORCE_PROD=1` → force production (overrides DEBUG +
|
|
62
|
+
/// simulator). The host app's CI sets this to exercise license
|
|
63
|
+
/// enforcement in non-RELEASE builds.
|
|
64
|
+
/// 2. `DVAI_FORCE_DEV=1` → force dev. Explicit operator opt-in.
|
|
65
|
+
/// 3. `#if DEBUG` → dev. Compile-time check — the DEBUG configuration
|
|
66
|
+
/// bypasses licensing entirely so the in-Xcode dev loop never
|
|
67
|
+
/// surfaces a license error.
|
|
68
|
+
/// 4. `targetEnvironment(simulator)` → dev. Same intent: a simulator
|
|
69
|
+
/// run is by construction a dev environment.
|
|
70
|
+
/// 5. otherwise → production, license required.
|
|
71
|
+
public func detectDevMode() -> (isDev: Bool, reason: String) {
|
|
72
|
+
// 1. Explicit env-var overrides (operator-level, runtime). Use
|
|
73
|
+
// `envVar` (libc getenv) so DVAI_FORCE_* mutated mid-process —
|
|
74
|
+
// e.g. by tests — is picked up.
|
|
75
|
+
let forceProd = envVar("DVAI_FORCE_PROD")
|
|
76
|
+
if forceProd == "1" || forceProd == "true" {
|
|
77
|
+
return (false, "DVAI_FORCE_PROD set")
|
|
78
|
+
}
|
|
79
|
+
let forceDev = envVar("DVAI_FORCE_DEV")
|
|
80
|
+
if forceDev == "1" || forceDev == "true" {
|
|
81
|
+
return (true, "DVAI_FORCE_DEV set")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. DEBUG configuration.
|
|
85
|
+
#if DEBUG
|
|
86
|
+
return (true, "DEBUG build configuration")
|
|
87
|
+
#else
|
|
88
|
+
// 3. Simulator (DEBUG would have already caught most simulator
|
|
89
|
+
// runs; this branch matters for a Release-configuration build
|
|
90
|
+
// happening to run on the simulator — rare, but harmless to
|
|
91
|
+
// bypass).
|
|
92
|
+
#if targetEnvironment(simulator)
|
|
93
|
+
return (true, "iOS Simulator")
|
|
94
|
+
#else
|
|
95
|
+
return (false, "production-class environment")
|
|
96
|
+
#endif
|
|
97
|
+
#endif
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Decide whether a license-payload `aud` entry matches the current
|
|
101
|
+
/// runtime audience. Supports exact match and `*.example.com` wildcard
|
|
102
|
+
/// matching for subdomain (here: bundle-id-suffix) binding. Returns
|
|
103
|
+
/// the matched `aud` pattern on success so it can be recorded for
|
|
104
|
+
/// audit, or `nil` on miss.
|
|
105
|
+
///
|
|
106
|
+
/// Match rules (identical to the JS-side `matchAudience`):
|
|
107
|
+
/// - `"foo"` matches `"foo"` exactly (case-insensitive)
|
|
108
|
+
/// - `"*.example.com"` matches `"example.com"` AND any
|
|
109
|
+
/// `"<sub>.example.com"`
|
|
110
|
+
/// - `"*"` matches any non-nil audience (intentionally permissive;
|
|
111
|
+
/// used for trial / site licenses that span all of a customer's
|
|
112
|
+
/// deployments)
|
|
113
|
+
///
|
|
114
|
+
/// A `nil` runtime audience matches only `"*"` entries — a build with
|
|
115
|
+
/// no bundle identifier can activate any-domain licenses but not
|
|
116
|
+
/// bundle-bound ones.
|
|
117
|
+
public func matchAudience(runtimeAudience: String?, audClaim: [String]) -> String? {
|
|
118
|
+
guard let runtime = runtimeAudience?.lowercased(), !runtime.isEmpty else {
|
|
119
|
+
return audClaim.contains("*") ? "*" : nil
|
|
120
|
+
}
|
|
121
|
+
for pattern in audClaim {
|
|
122
|
+
let p = pattern.lowercased()
|
|
123
|
+
if p == "*" { return pattern } // permissive wildcard
|
|
124
|
+
if p == runtime { return pattern } // exact match
|
|
125
|
+
if p.hasPrefix("*.") {
|
|
126
|
+
let suffix = String(p.dropFirst(2))
|
|
127
|
+
if runtime == suffix || runtime.hasSuffix("." + suffix) {
|
|
128
|
+
return pattern
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return nil
|
|
133
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* License-file discovery for the iOS SDK.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `packages/dvai-bridge-core/src/license/discovery.ts` but with
|
|
5
|
+
* iOS-native locations (Bundle resources, Documents directory, App Group
|
|
6
|
+
* containers) in place of the JS side's CWD-based filesystem walk.
|
|
7
|
+
*
|
|
8
|
+
* Priority order (first hit wins):
|
|
9
|
+
*
|
|
10
|
+
* 1. `LicenseDiscoveryOptions.token` — inline JWT string. Useful when
|
|
11
|
+
* the host app fetches its license over the network at runtime
|
|
12
|
+
* and wants to inject the result without touching disk.
|
|
13
|
+
*
|
|
14
|
+
* 2. `LicenseDiscoveryOptions.path` — explicit file path. Useful for
|
|
15
|
+
* tests or for hosts that store the .jwt outside the standard
|
|
16
|
+
* locations. If this path is set but the file isn't there or
|
|
17
|
+
* isn't readable, this is a real miss (we do NOT silently fall
|
|
18
|
+
* through to auto-discovery) — same semantics as the JS side.
|
|
19
|
+
*
|
|
20
|
+
* 3. `DVAI_LICENSE_PATH` env var. Operator-supplied path; helpful
|
|
21
|
+
* for CI / TestFlight where you don't want to ship the .jwt in
|
|
22
|
+
* the bundle. Misses fall through to (5).
|
|
23
|
+
*
|
|
24
|
+
* 4. `DVAI_LICENSE_TOKEN` env var. Inline JWT in the environment.
|
|
25
|
+
*
|
|
26
|
+
* 5. `dvai-license.jwt` resource in `Bundle.main`. The dev-friendly
|
|
27
|
+
* happy path: add the file to the app's "Copy Bundle Resources"
|
|
28
|
+
* phase, the SDK picks it up automatically.
|
|
29
|
+
*
|
|
30
|
+
* 6. `Application Support/dvai-bridge/dvai-license.jwt` in the app's
|
|
31
|
+
* sandbox. Useful for licenses fetched after install (e.g. via
|
|
32
|
+
* an in-app purchase flow).
|
|
33
|
+
*
|
|
34
|
+
* 7. `Documents/dvai-license.jwt`. Last-resort fallback — visible
|
|
35
|
+
* via iTunes File Sharing if the app opts in, so handy for
|
|
36
|
+
* side-loading a license during development.
|
|
37
|
+
*
|
|
38
|
+
* Returning `nil` means "no license token found"; the validator treats
|
|
39
|
+
* that as the free-tier case (after the dev-mode bypass).
|
|
40
|
+
*/
|
|
41
|
+
import Foundation
|
|
42
|
+
|
|
43
|
+
/// Default filename the SDK looks for in Bundle / Documents / App Support.
|
|
44
|
+
/// Chosen to be self-documenting and to encourage commit-to-vcs (so the
|
|
45
|
+
/// license travels with the code).
|
|
46
|
+
public let DVAI_DEFAULT_LICENSE_FILENAME = "dvai-license.jwt"
|
|
47
|
+
|
|
48
|
+
public struct LicenseDiscoveryOptions: Sendable {
|
|
49
|
+
/// Pre-loaded JWT string (skips all filesystem lookups).
|
|
50
|
+
public var token: String?
|
|
51
|
+
/// Explicit path to load from. Overrides auto-discovery.
|
|
52
|
+
public var path: String?
|
|
53
|
+
/// App Group identifier to also search (e.g. for shared licenses
|
|
54
|
+
/// across an app + extension). Optional; default `nil` skips the
|
|
55
|
+
/// App Group lookup.
|
|
56
|
+
public var appGroupIdentifier: String?
|
|
57
|
+
|
|
58
|
+
public init(
|
|
59
|
+
token: String? = nil,
|
|
60
|
+
path: String? = nil,
|
|
61
|
+
appGroupIdentifier: String? = nil
|
|
62
|
+
) {
|
|
63
|
+
self.token = token
|
|
64
|
+
self.path = path
|
|
65
|
+
self.appGroupIdentifier = appGroupIdentifier
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Best-effort load of a license JWT. Returns the raw token string +
|
|
70
|
+
/// source description on success, or nil on miss. Errors during loading
|
|
71
|
+
/// (file not found, permission denied) collapse to nil — the
|
|
72
|
+
/// validator's responsibility is to handle the no-license case
|
|
73
|
+
/// gracefully, not the discovery layer's.
|
|
74
|
+
///
|
|
75
|
+
/// Source descriptions are surfaced in `LicenseStatus.freeProd.reason`
|
|
76
|
+
/// for debug, and shown in dashboards so the developer can see which
|
|
77
|
+
/// of the seven locations resolved.
|
|
78
|
+
public func discoverLicenseToken(
|
|
79
|
+
options: LicenseDiscoveryOptions = LicenseDiscoveryOptions()
|
|
80
|
+
) -> (token: String, source: String)? {
|
|
81
|
+
// 1. Explicit token wins.
|
|
82
|
+
if let token = options.token, !token.isEmpty {
|
|
83
|
+
return (token.trimmingCharacters(in: .whitespacesAndNewlines), "options.token")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Explicit path.
|
|
87
|
+
if let path = options.path, !path.isEmpty {
|
|
88
|
+
if let loaded = tryLoadFromPath(path) {
|
|
89
|
+
return (loaded, path)
|
|
90
|
+
}
|
|
91
|
+
return nil // explicit miss — do NOT fall through
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. Env-var path. Use `envVar` (libc getenv) so DVAI_LICENSE_PATH
|
|
95
|
+
// mutated mid-process — e.g. by tests — is picked up.
|
|
96
|
+
if let envPath = envVar("DVAI_LICENSE_PATH") {
|
|
97
|
+
if let loaded = tryLoadFromPath(envPath) {
|
|
98
|
+
return (loaded, "DVAI_LICENSE_PATH=\(envPath)")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. Env-var inline token.
|
|
103
|
+
if let envToken = envVar("DVAI_LICENSE_TOKEN") {
|
|
104
|
+
return (
|
|
105
|
+
envToken.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
106
|
+
"DVAI_LICENSE_TOKEN env var"
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 5. Bundle resource (dvai-license.jwt next to the app's other
|
|
111
|
+
// bundled resources).
|
|
112
|
+
if let bundleURL = Bundle.main.url(forResource: "dvai-license", withExtension: "jwt") {
|
|
113
|
+
if let loaded = tryLoadFromPath(bundleURL.path) {
|
|
114
|
+
return (loaded, "Bundle.main resource dvai-license.jwt")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 6. Application Support / dvai-bridge / dvai-license.jwt.
|
|
119
|
+
if let appSupportURL = (try? FileManager.default.url(
|
|
120
|
+
for: .applicationSupportDirectory,
|
|
121
|
+
in: .userDomainMask,
|
|
122
|
+
appropriateFor: nil,
|
|
123
|
+
create: false
|
|
124
|
+
)) {
|
|
125
|
+
let candidate = appSupportURL
|
|
126
|
+
.appendingPathComponent("dvai-bridge", isDirectory: true)
|
|
127
|
+
.appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
|
|
128
|
+
if let loaded = tryLoadFromPath(candidate.path) {
|
|
129
|
+
return (loaded, candidate.path)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 7. Documents directory.
|
|
134
|
+
if let docsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
135
|
+
let candidate = docsURL.appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME)
|
|
136
|
+
if let loaded = tryLoadFromPath(candidate.path) {
|
|
137
|
+
return (loaded, candidate.path)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 8. (Optional) App Group container, if configured.
|
|
142
|
+
if let groupId = options.appGroupIdentifier, !groupId.isEmpty {
|
|
143
|
+
if let groupURL = FileManager.default
|
|
144
|
+
.containerURL(forSecurityApplicationGroupIdentifier: groupId)?
|
|
145
|
+
.appendingPathComponent(DVAI_DEFAULT_LICENSE_FILENAME) {
|
|
146
|
+
if let loaded = tryLoadFromPath(groupURL.path) {
|
|
147
|
+
return (loaded, groupURL.path)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Read a file at `path` and return its trimmed UTF-8 contents, or
|
|
156
|
+
/// nil on any error (missing / permission / encoding).
|
|
157
|
+
private func tryLoadFromPath(_ path: String) -> String? {
|
|
158
|
+
let fm = FileManager.default
|
|
159
|
+
guard fm.fileExists(atPath: path) else { return nil }
|
|
160
|
+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
|
|
161
|
+
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
|
162
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
163
|
+
return trimmed.isEmpty ? nil : trimmed
|
|
164
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DVAI-Bridge license validator — offline JWT verification on iOS.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `packages/dvai-bridge-core/src/license/LicenseValidator.ts`:
|
|
5
|
+
*
|
|
6
|
+
* 1. The license is a signed JWT (header + payload + ECDSA P-256
|
|
7
|
+
* signature), issued by the operator's own license-generator
|
|
8
|
+
* service from a private key they hold.
|
|
9
|
+
* 2. The SDK ships only with public keys (see `PublicKeys.swift`) and
|
|
10
|
+
* cannot itself produce valid licenses — so reverse-engineering
|
|
11
|
+
* the bundled SDK gains nothing.
|
|
12
|
+
* 3. At runtime, the validator does signature + expiry + audience +
|
|
13
|
+
* platform binding checks. Failure of any check collapses to
|
|
14
|
+
* free-tier (with attribution badge), not a hard error — the SDK
|
|
15
|
+
* stays usable for hobbyists / community use UNLESS the caller
|
|
16
|
+
* uses `validateAndAssert()` (which throws — used by
|
|
17
|
+
* `DVAIBridge.start(...)` to enforce BSL 1.1).
|
|
18
|
+
*
|
|
19
|
+
* Network calls: zero. The whole flow is offline by design.
|
|
20
|
+
*
|
|
21
|
+
* Algorithm-confusion defense: this validator ONLY accepts ES256.
|
|
22
|
+
* `alg: none`, `HS256`, `RS256` etc. are rejected at the header-parsing
|
|
23
|
+
* step BEFORE handing the token to JWTKit, so a forged header can't
|
|
24
|
+
* trick us into validating against an unintended key.
|
|
25
|
+
*/
|
|
26
|
+
import Foundation
|
|
27
|
+
#if !COCOAPODS
|
|
28
|
+
import JWTKit
|
|
29
|
+
#endif
|
|
30
|
+
|
|
31
|
+
public struct LicenseValidatorOptions: Sendable {
|
|
32
|
+
/// Pre-loaded JWT string. Skips filesystem lookups.
|
|
33
|
+
public var token: String?
|
|
34
|
+
/// Explicit path to load from. Overrides auto-discovery.
|
|
35
|
+
public var path: String?
|
|
36
|
+
/// Override the public-key registry. Defaults to `DVAI_PUBLIC_KEYS`
|
|
37
|
+
/// from `PublicKeys.swift`. Tests inject their own keypair via this
|
|
38
|
+
/// option so they can sign + verify against a deterministic key
|
|
39
|
+
/// without polluting the production registry.
|
|
40
|
+
public var publicKeys: [String: DvaiPublicKeyJwk]?
|
|
41
|
+
/// If true, accept tokens signed under `DVAI_PLACEHOLDER_KID`
|
|
42
|
+
/// (i.e. the built-in placeholder public key). Off by default — a
|
|
43
|
+
/// real production build must replace the placeholder with a
|
|
44
|
+
/// generated key. Tests set this to true.
|
|
45
|
+
public var allowPlaceholderKey: Bool
|
|
46
|
+
/// App Group identifier to also search during discovery.
|
|
47
|
+
public var appGroupIdentifier: String?
|
|
48
|
+
|
|
49
|
+
public init(
|
|
50
|
+
token: String? = nil,
|
|
51
|
+
path: String? = nil,
|
|
52
|
+
publicKeys: [String: DvaiPublicKeyJwk]? = nil,
|
|
53
|
+
allowPlaceholderKey: Bool = false,
|
|
54
|
+
appGroupIdentifier: String? = nil
|
|
55
|
+
) {
|
|
56
|
+
self.token = token
|
|
57
|
+
self.path = path
|
|
58
|
+
self.publicKeys = publicKeys
|
|
59
|
+
self.allowPlaceholderKey = allowPlaceholderKey
|
|
60
|
+
self.appGroupIdentifier = appGroupIdentifier
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Validate a DVAI-Bridge license once at SDK startup. The returned
|
|
65
|
+
/// `LicenseStatus` is the discriminated value the rest of the SDK
|
|
66
|
+
/// dispatches on. `validate()` never throws on validation failure —
|
|
67
|
+
/// it returns `.freeProd` / `.freeExpired`. `validateAndAssert()`
|
|
68
|
+
/// throws `LicenseRequiredError` for those two cases — used by
|
|
69
|
+
/// `DVAIBridge.start(...)` to enforce BSL 1.1.
|
|
70
|
+
public final class LicenseValidator: Sendable {
|
|
71
|
+
private let opts: LicenseValidatorOptions
|
|
72
|
+
|
|
73
|
+
public init(options: LicenseValidatorOptions = LicenseValidatorOptions()) {
|
|
74
|
+
self.opts = options
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Validate WITHOUT throwing. Returns a `LicenseStatus` describing
|
|
78
|
+
/// what the validator determined; never throws on missing /
|
|
79
|
+
/// invalid / expired licenses.
|
|
80
|
+
///
|
|
81
|
+
/// Useful for host-app dashboards that want to display the
|
|
82
|
+
/// licensee / expiry / fallback reason without halting SDK
|
|
83
|
+
/// startup, and for tests. The SDK's `start(_:)` calls
|
|
84
|
+
/// `validateAndAssert()` instead — which throws.
|
|
85
|
+
///
|
|
86
|
+
/// Idempotent; safe to call multiple times.
|
|
87
|
+
public func validate() async -> LicenseStatus {
|
|
88
|
+
// 1. Dev-mode bypass — license required only in production.
|
|
89
|
+
let dev = detectDevMode()
|
|
90
|
+
if dev.isDev {
|
|
91
|
+
return .freeDev(reason: dev.reason)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Discover the token. Returns nil when no license source is
|
|
95
|
+
// configured AND auto-discovery fails — fall through to
|
|
96
|
+
// free-prod so the SDK still works for community / hobbyist
|
|
97
|
+
// users (and the assert variant can then throw a clear error).
|
|
98
|
+
let discovery = LicenseDiscoveryOptions(
|
|
99
|
+
token: opts.token,
|
|
100
|
+
path: opts.path,
|
|
101
|
+
appGroupIdentifier: opts.appGroupIdentifier
|
|
102
|
+
)
|
|
103
|
+
guard let discovered = discoverLicenseToken(options: discovery) else {
|
|
104
|
+
return .freeProd(reason:
|
|
105
|
+
"no license token found; checked options.token, options.path, " +
|
|
106
|
+
"DVAI_LICENSE_PATH env, DVAI_LICENSE_TOKEN env, Bundle.main " +
|
|
107
|
+
"resource dvai-license.jwt, Application Support / dvai-bridge / " +
|
|
108
|
+
"dvai-license.jwt, and Documents / dvai-license.jwt"
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Verify signature + claims.
|
|
113
|
+
let platform = detectPlatform()
|
|
114
|
+
let audience = detectAudience()
|
|
115
|
+
return await verifyToken(discovered.token, platform: platform, runtimeAudience: audience)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Strict validation entry point used by the SDK at startup. Returns
|
|
119
|
+
/// `LicenseStatus` on success (`commercial`, `trial`, `freeDev`) and
|
|
120
|
+
/// THROWS `LicenseRequiredError` on `freeProd` / `freeExpired`.
|
|
121
|
+
///
|
|
122
|
+
/// This is the BSL 1.1 enforcement point: in production / release
|
|
123
|
+
/// builds (any non-dev-mode environment), the SDK refuses to operate
|
|
124
|
+
/// without a valid commercial or trial license. Developers running
|
|
125
|
+
/// in DEBUG / simulator / DVAI_FORCE_DEV are unaffected — those
|
|
126
|
+
/// return a `.freeDev` status and the SDK proceeds normally.
|
|
127
|
+
///
|
|
128
|
+
/// Use `validate()` when you want to inspect the status without
|
|
129
|
+
/// halting startup (host-app dashboards, test fixtures).
|
|
130
|
+
@discardableResult
|
|
131
|
+
public func validateAndAssert() async throws -> LicenseStatus {
|
|
132
|
+
let status = await validate()
|
|
133
|
+
switch status {
|
|
134
|
+
case .freeProd, .freeExpired:
|
|
135
|
+
throw LicenseRequiredError(
|
|
136
|
+
message: buildRequiredErrorMessage(status: status),
|
|
137
|
+
status: status
|
|
138
|
+
)
|
|
139
|
+
default:
|
|
140
|
+
return status
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - Internal: token verification
|
|
145
|
+
|
|
146
|
+
private func verifyToken(
|
|
147
|
+
_ token: String,
|
|
148
|
+
platform: DvaiPlatform,
|
|
149
|
+
runtimeAudience: String?
|
|
150
|
+
) async -> LicenseStatus {
|
|
151
|
+
#if COCOAPODS
|
|
152
|
+
// CocoaPods fallback — commercial licenses require SwiftPM.
|
|
153
|
+
// This is documented in PUBLISHING.md.
|
|
154
|
+
return .freeProd(reason: "Commercial license validation requires SwiftPM (JWTKit). " +
|
|
155
|
+
"CocoaPods builds only support community/hobbyist use.")
|
|
156
|
+
#else
|
|
157
|
+
let registry = opts.publicKeys ?? DVAI_PUBLIC_KEYS
|
|
158
|
+
|
|
159
|
+
// Parse the header ourselves to (a) refuse non-ES256 alg early
|
|
160
|
+
// (algorithm-confusion defense — we never even hand a non-ES256
|
|
161
|
+
// token to the JWT library), and (b) pick the right public key
|
|
162
|
+
// by kid before invoking signature verification.
|
|
163
|
+
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
|
|
164
|
+
if parts.count != 3 || parts[0].isEmpty {
|
|
165
|
+
return .freeProd(reason: "license token is not a well-formed JWT (need 3 segments)")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let headerJSON: [String: Any]
|
|
169
|
+
do {
|
|
170
|
+
guard let headerData = base64UrlDecode(String(parts[0])) else {
|
|
171
|
+
return .freeProd(reason: "license token header is not base64url-decodable")
|
|
172
|
+
}
|
|
173
|
+
guard let obj = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] else {
|
|
174
|
+
return .freeProd(reason: "license token header is not a JSON object")
|
|
175
|
+
}
|
|
176
|
+
headerJSON = obj
|
|
177
|
+
} catch {
|
|
178
|
+
return .freeProd(reason: "license token header is not parseable JSON: \(error.localizedDescription)")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let headerAlg = headerJSON["alg"] as? String
|
|
182
|
+
if headerAlg != "ES256" {
|
|
183
|
+
// Refuse `alg: none` and any non-ES256 algorithm. Critical
|
|
184
|
+
// defense against the classic JWT algorithm-confusion
|
|
185
|
+
// vulnerability.
|
|
186
|
+
return .freeProd(reason:
|
|
187
|
+
"license token uses unsupported alg \"\(headerAlg ?? "(missing)")\", expected ES256"
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
guard let kid = headerJSON["kid"] as? String, !kid.isEmpty else {
|
|
192
|
+
return .freeProd(reason: "license token header missing kid; cannot select verification key")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
guard let jwk = registry[kid] else {
|
|
196
|
+
return .freeProd(reason:
|
|
197
|
+
"license token kid \"\(kid)\" is not in the SDK's public-key " +
|
|
198
|
+
"registry; either the key was rotated and you're on an old SDK, " +
|
|
199
|
+
"or the token was signed with a key we don't recognise"
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if kid == DVAI_PLACEHOLDER_KID && !opts.allowPlaceholderKey {
|
|
204
|
+
return .freeProd(reason:
|
|
205
|
+
"license token signed with the placeholder key (kid \"\(DVAI_PLACEHOLDER_KID)\"); " +
|
|
206
|
+
"replace the placeholder in PublicKeys.swift with a real key generated " +
|
|
207
|
+
"via scripts/license/generate-keypair.mjs before issuing real licenses"
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Hand the (alg-vetted, kid-known) token to JWTKit for signature
|
|
212
|
+
// verification. We register only the matched key — under that
|
|
213
|
+
// kid — so the verifier has no chance to pick anything else.
|
|
214
|
+
let keys = JWTKeyCollection()
|
|
215
|
+
do {
|
|
216
|
+
// ES256PublicKey is JWTKit's public typealias for
|
|
217
|
+
// `ECDSA.PublicKey<P256>` — avoids leaking the swift-crypto
|
|
218
|
+
// `P256` symbol into our source.
|
|
219
|
+
let ecdsaPublic = try ES256PublicKey(parameters: (x: jwk.x, y: jwk.y))
|
|
220
|
+
await keys.add(ecdsa: ecdsaPublic, kid: JWKIdentifier(string: kid))
|
|
221
|
+
} catch {
|
|
222
|
+
return .freeProd(reason:
|
|
223
|
+
"license token kid \"\(kid)\" points at a malformed public key in the SDK's " +
|
|
224
|
+
"registry (could not parse x/y coordinates): \(error.localizedDescription)"
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let payload: DvaiLicensePayload
|
|
229
|
+
do {
|
|
230
|
+
payload = try await keys.verify(token, as: DvaiLicensePayload.self)
|
|
231
|
+
} catch let jwtError as JWTError {
|
|
232
|
+
// JWTKit's signatureVerificationFailed is what we get for a
|
|
233
|
+
// tampered token. Surface it specifically.
|
|
234
|
+
switch jwtError.errorType {
|
|
235
|
+
case .signatureVerificationFailed:
|
|
236
|
+
return .freeProd(reason:
|
|
237
|
+
"license token signature did not verify against kid \"\(kid)\"; " +
|
|
238
|
+
"the token may have been tampered with or was signed by a different key"
|
|
239
|
+
)
|
|
240
|
+
case .malformedToken:
|
|
241
|
+
return .freeProd(reason: "license token is malformed: \(jwtError.reason ?? "(no detail)")")
|
|
242
|
+
default:
|
|
243
|
+
return .freeProd(reason: "license token verification failed: \(jwtError)")
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
return .freeProd(reason: "license token verification failed: \(error.localizedDescription)")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -----------------------------------------------------------------
|
|
250
|
+
// Signature passed. Now run our own claim checks so each failure
|
|
251
|
+
// mode gets a specific, actionable error message.
|
|
252
|
+
// -----------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
// Issuer must be exactly "DVAI-Bridge".
|
|
255
|
+
if payload.iss != "DVAI-Bridge" {
|
|
256
|
+
return .freeProd(reason:
|
|
257
|
+
"license token issuer is \"\(payload.iss)\", expected \"DVAI-Bridge\""
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Expiry: if exp is in the past, surface a specific freeExpired
|
|
262
|
+
// status (the licensee + when so the dashboard can prompt
|
|
263
|
+
// renewal).
|
|
264
|
+
let nowSeconds = Int64(Date().timeIntervalSince1970)
|
|
265
|
+
if payload.exp <= nowSeconds {
|
|
266
|
+
return .freeExpired(licensee: payload.licensee, expiredAt: payload.exp)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Tier must be one of the live tiers.
|
|
270
|
+
let tier: LicenseTier
|
|
271
|
+
switch payload.tier {
|
|
272
|
+
case "commercial": tier = .commercial
|
|
273
|
+
case "trial": tier = .trial
|
|
274
|
+
default:
|
|
275
|
+
return .freeProd(reason:
|
|
276
|
+
"license token tier \"\(payload.tier)\" is not recognised; " +
|
|
277
|
+
"expected \"commercial\" or \"trial\""
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Platforms must include our runtime platform.
|
|
282
|
+
if !payload.platforms.contains(platform.rawValue) {
|
|
283
|
+
return .freeProd(reason:
|
|
284
|
+
"license token does not authorise platform \"\(platform.rawValue)\"; " +
|
|
285
|
+
"the token covers [\(payload.platforms.joined(separator: ", "))]"
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Audience must match (exact / wildcard / *).
|
|
290
|
+
guard let matched = matchAudience(runtimeAudience: runtimeAudience, audClaim: payload.aud) else {
|
|
291
|
+
let noneSuffix = runtimeAudience == nil
|
|
292
|
+
? " — set DVAI_AUDIENCE in your environment, or use a \"*\" aud entry for any-domain licenses"
|
|
293
|
+
: ""
|
|
294
|
+
return .freeProd(reason:
|
|
295
|
+
"license token's audience entries [\(payload.aud.joined(separator: ", "))] " +
|
|
296
|
+
"do not match the current runtime audience \"\(runtimeAudience ?? "(none)")\"" +
|
|
297
|
+
noneSuffix
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
switch tier {
|
|
302
|
+
case .commercial:
|
|
303
|
+
return .commercial(
|
|
304
|
+
licensee: payload.licensee,
|
|
305
|
+
expiresAt: payload.exp,
|
|
306
|
+
platform: platform,
|
|
307
|
+
audienceMatched: matched
|
|
308
|
+
)
|
|
309
|
+
case .trial:
|
|
310
|
+
return .trial(
|
|
311
|
+
licensee: payload.licensee,
|
|
312
|
+
expiresAt: payload.exp,
|
|
313
|
+
platform: platform,
|
|
314
|
+
audienceMatched: matched
|
|
315
|
+
)
|
|
316
|
+
default:
|
|
317
|
+
// Unreachable (we filtered above), but the compiler can't
|
|
318
|
+
// see that.
|
|
319
|
+
return .freeProd(reason: "internal validator state error")
|
|
320
|
+
}
|
|
321
|
+
#endif
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// MARK: - Helpers
|
|
326
|
+
|
|
327
|
+
/// Decode a base64url string (RFC 4648 §5) into raw bytes. The JWT
|
|
328
|
+
/// header / payload segments use this encoding (no padding, `-` and `_`
|
|
329
|
+
/// instead of `+` and `/`).
|
|
330
|
+
private func base64UrlDecode(_ s: String) -> Data? {
|
|
331
|
+
var b64 = s.replacingOccurrences(of: "-", with: "+")
|
|
332
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
333
|
+
let pad = b64.count % 4
|
|
334
|
+
if pad > 0 {
|
|
335
|
+
b64 += String(repeating: "=", count: 4 - pad)
|
|
336
|
+
}
|
|
337
|
+
return Data(base64Encoded: b64)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Build the developer-facing error message for `LicenseRequiredError`.
|
|
341
|
+
/// Intentionally verbose: it tells the developer exactly what failed,
|
|
342
|
+
/// how to resolve it, where to put the license file, and how to bypass
|
|
343
|
+
/// for local development. This message will be printed to Xcode's
|
|
344
|
+
/// console or a crash log — make it readable in both.
|
|
345
|
+
internal func buildRequiredErrorMessage(status: LicenseStatus) -> String {
|
|
346
|
+
let header = """
|
|
347
|
+
|
|
348
|
+
DVAI-Bridge Commercial License Required
|
|
349
|
+
=======================================
|
|
350
|
+
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
let reason: String
|
|
354
|
+
switch status {
|
|
355
|
+
case .freeExpired(let licensee, let expiredAt):
|
|
356
|
+
let date = Date(timeIntervalSince1970: TimeInterval(expiredAt))
|
|
357
|
+
let formatter = ISO8601DateFormatter()
|
|
358
|
+
formatter.formatOptions = [.withInternetDateTime]
|
|
359
|
+
reason = "License for \"\(licensee)\" expired at \(formatter.string(from: date))."
|
|
360
|
+
case .freeProd(let r):
|
|
361
|
+
reason = r
|
|
362
|
+
default:
|
|
363
|
+
reason = "(unknown status)"
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let remediation = """
|
|
367
|
+
|
|
368
|
+
This SDK is licensed under BSL 1.1 and requires a valid commercial
|
|
369
|
+
or trial license to run in production / release builds.
|
|
370
|
+
|
|
371
|
+
To resolve:
|
|
372
|
+
1. Obtain a license at https://deepvoiceai.com/dvai-bridge/license
|
|
373
|
+
2. Place the file at one of these locations (any will work):
|
|
374
|
+
- <App.bundle>/dvai-license.jwt (add to Copy Bundle Resources)
|
|
375
|
+
- the path you pass as LicenseValidatorOptions.path
|
|
376
|
+
- the path in $DVAI_LICENSE_PATH
|
|
377
|
+
- inline JWT in LicenseValidatorOptions.token or $DVAI_LICENSE_TOKEN
|
|
378
|
+
- <Application Support>/dvai-bridge/dvai-license.jwt
|
|
379
|
+
- <Documents>/dvai-license.jwt
|
|
380
|
+
3. Re-run.
|
|
381
|
+
|
|
382
|
+
Developing locally? The SDK auto-detects dev mode on:
|
|
383
|
+
- DEBUG build configuration (compile-time #if DEBUG)
|
|
384
|
+
- iOS Simulator (compile-time targetEnvironment(simulator))
|
|
385
|
+
- DVAI_FORCE_DEV=1 environment variable (explicit override)
|
|
386
|
+
Any of these silences this error and lets the SDK run without a
|
|
387
|
+
license.
|
|
388
|
+
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
return header + "\n" + reason + "\n" + remediation
|
|
392
|
+
}
|