@dvai-bridge/ios 4.0.0 → 4.0.1

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 (41) hide show
  1. package/Package.swift +104 -104
  2. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
  3. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
  4. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
  5. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
  6. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
  7. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
  8. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
  9. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
  10. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
  11. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
  12. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
  13. package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
  14. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
  15. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
  16. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
  17. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
  18. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
  19. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
  20. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
  21. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
  22. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
  23. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
  24. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
  25. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
  26. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
  27. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
  28. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
  29. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
  30. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
  31. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
  32. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
  33. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
  34. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
  35. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
  36. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
  37. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
  38. package/package.json +3 -4
  39. package/DVAIBridge.podspec +0 -120
  40. package/LICENSE +0 -51
  41. package/README.md +0 -199
@@ -1,392 +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
- }
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
+ }