@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.
Files changed (60) hide show
  1. package/DVAIBridge.podspec +120 -0
  2. package/LICENSE +51 -0
  3. package/Package.swift +104 -0
  4. package/README.md +199 -0
  5. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
  6. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
  7. package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
  8. package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
  9. package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
  10. package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
  11. package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
  12. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
  13. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
  14. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
  15. package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
  16. package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
  17. package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
  18. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
  19. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
  20. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
  21. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
  22. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
  23. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
  24. package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
  25. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
  26. package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
  27. package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
  28. package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
  29. package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
  30. package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
  31. package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
  32. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
  33. package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
  34. package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
  35. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
  36. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
  37. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
  38. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
  39. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
  40. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
  41. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
  42. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
  43. package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
  44. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
  45. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
  46. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
  47. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
  48. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
  49. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
  50. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
  51. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
  52. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
  53. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
  54. package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
  55. package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
  56. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
  57. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
  58. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
  59. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
  60. 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
+ }