@dvai-bridge/android 4.0.0 → 4.0.2

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.
@@ -1,400 +1,400 @@
1
- package co.deepvoiceai.bridge.license
2
-
3
- import android.content.Context
4
- import android.util.Base64
5
- import com.nimbusds.jose.JOSEException
6
- import com.nimbusds.jose.JWSAlgorithm
7
- import com.nimbusds.jose.crypto.ECDSAVerifier
8
- import com.nimbusds.jose.jwk.Curve
9
- import com.nimbusds.jose.jwk.ECKey
10
- import com.nimbusds.jose.util.Base64URL
11
- import com.nimbusds.jwt.SignedJWT
12
- import kotlinx.coroutines.Dispatchers
13
- import kotlinx.coroutines.withContext
14
- import org.json.JSONObject
15
- import java.text.ParseException
16
- import java.util.Date
17
-
18
- /**
19
- * DVAI-Bridge license validator — offline JWT verification.
20
- *
21
- * Kotlin port of `packages/dvai-bridge-core/src/license/LicenseValidator.ts`.
22
- * Verifies a JWT (header + payload + ECDSA P-256 signature) using
23
- * `nimbus-jose-jwt`. The SDK ships only with public keys (see
24
- * [DvaiPublicKeys]) and cannot itself produce valid licenses — so
25
- * reverse-engineering the bundled APK gains nothing.
26
- *
27
- * Network calls: zero. The whole flow is offline by design — there's
28
- * no "phone home" step, no license server polling, no DRM beacon.
29
- *
30
- * Android divergence from the JS validator: in production (non-DEBUG)
31
- * Android builds, both [validateAndAssert] and the SDK's `start(...)`
32
- * THROW [LicenseRequiredError] rather than falling back to a watermarked
33
- * free-tier. This matches the iOS validator and the BSL 1.1 commercial
34
- * enforcement story for native mobile distributions.
35
- *
36
- * @param context Application context (typically
37
- * `applicationContext`). Required for audience
38
- * detection (package name) and discovery
39
- * (assets, raw resources, internal storage).
40
- * @param token Pre-loaded JWT string. If non-null, skips all
41
- * auto-discovery.
42
- * @param path Explicit license file path. Overrides
43
- * auto-discovery if non-null.
44
- * @param hostBuildConfigDebug The host app's `BuildConfig.DEBUG` value. Pass
45
- * this from `Application.onCreate()` so the
46
- * validator can bypass enforcement on debug builds.
47
- * Falls back to `ApplicationInfo.FLAG_DEBUGGABLE`
48
- * if null.
49
- * @param publicKeys Override for the public-key registry. Defaults
50
- * to [DvaiPublicKeys.REGISTRY]. Tests inject
51
- * their own keypair so they can sign + verify
52
- * against a deterministic key without polluting
53
- * the production registry.
54
- * @param allowPlaceholderKey If true, accept tokens signed under
55
- * [PLACEHOLDER_KID]. Off by default — a real
56
- * production build must replace the placeholder
57
- * with a generated key. Tests set this to true.
58
- */
59
- class LicenseValidator(
60
- private val context: Context,
61
- private val token: String? = null,
62
- private val path: String? = null,
63
- private val hostBuildConfigDebug: Boolean? = null,
64
- private val publicKeys: Map<String, DvaiPublicKeyJwk> = DvaiPublicKeys.REGISTRY,
65
- private val allowPlaceholderKey: Boolean = false,
66
- ) {
67
- /**
68
- * Validate WITHOUT throwing. Returns a [LicenseStatus] describing what
69
- * the validator determined; never throws on missing / invalid /
70
- * expired licenses. Useful for host-app dashboards that want to
71
- * display the licensee / expiry / fallback reason without halting
72
- * SDK startup, and for tests.
73
- *
74
- * Idempotent; safe to call multiple times.
75
- */
76
- suspend fun validate(): LicenseStatus = withContext(Dispatchers.IO) {
77
- // 1. Dev-mode bypass — license required only in production.
78
- val dev = detectDevMode(context, hostBuildConfigDebug)
79
- if (dev.isDev) {
80
- return@withContext LicenseStatus.FreeDev(dev.reason)
81
- }
82
-
83
- // 2. Discover the token. Returns null when no license source is
84
- // configured AND auto-discovery fails.
85
- val discovered = discoverLicenseToken(
86
- context,
87
- LicenseDiscoveryOptions(token = token, path = path),
88
- )
89
- if (discovered == null) {
90
- return@withContext LicenseStatus.FreeProd(
91
- "no license token found; checked config.licenseToken, " +
92
- "config.licenseKeyPath, DVAI_LICENSE_PATH env, " +
93
- "DVAI_LICENSE_TOKEN env, assets/$DEFAULT_LICENSE_FILENAME, " +
94
- "res/raw/$DEFAULT_LICENSE_RAW_RESOURCE_NAME, " +
95
- "and filesDir/$DEFAULT_LICENSE_FILENAME",
96
- )
97
- }
98
-
99
- // 3. Verify signature + claims with nimbus-jose-jwt.
100
- verifyToken(discovered.token, detectPlatform(), detectAudience(context))
101
- }
102
-
103
- /**
104
- * Strict validation entry point used by the SDK at startup. Returns
105
- * [LicenseStatus] on success ([LicenseStatus.Commercial],
106
- * [LicenseStatus.Trial], [LicenseStatus.FreeDev]) and THROWS
107
- * [LicenseRequiredError] on [LicenseStatus.FreeProd] /
108
- * [LicenseStatus.FreeExpired].
109
- *
110
- * This is the BSL 1.1 enforcement point: in production / release
111
- * builds (any non-dev-mode environment), the SDK refuses to operate
112
- * without a valid commercial or trial license. Developers running on
113
- * debug builds / `BuildConfig.DEBUG=true` / explicit DVAI_FORCE_DEV
114
- * are unaffected — those return a [LicenseStatus.FreeDev] status and
115
- * the SDK proceeds normally.
116
- *
117
- * Use [validate] instead when you want to inspect the status without
118
- * halting startup (host-app dashboards, test fixtures).
119
- */
120
- suspend fun validateAndAssert(): LicenseStatus {
121
- val status = validate()
122
- when (status) {
123
- is LicenseStatus.FreeProd ->
124
- throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
125
- is LicenseStatus.FreeExpired ->
126
- throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
127
- else -> return status
128
- }
129
- }
130
-
131
- private fun verifyToken(
132
- token: String,
133
- platform: DvaiPlatform,
134
- runtimeAudience: String?,
135
- ): LicenseStatus {
136
- // Parse the JWT structure first so we can read the header to pick
137
- // the right public key. We could let nimbus iterate but specifying
138
- // the key up-front gives clearer error messages on misses.
139
- val parts = token.split(".")
140
- if (parts.size != 3 || parts[0].isEmpty()) {
141
- return LicenseStatus.FreeProd(
142
- "license token is not a well-formed JWT (need 3 segments)",
143
- )
144
- }
145
-
146
- val headerJson: JSONObject = try {
147
- JSONObject(base64UrlDecodeUtf8(parts[0]))
148
- } catch (e: Throwable) {
149
- return LicenseStatus.FreeProd(
150
- "license token header is not parseable JSON: ${e.message ?: e.javaClass.simpleName}",
151
- )
152
- }
153
-
154
- val alg = headerJson.optString("alg", "")
155
- if (alg != "ES256") {
156
- // Refuse `alg: none` and any non-ES256 algorithm. Critical
157
- // defense against the classic JWT algorithm-confusion vulnerability.
158
- return LicenseStatus.FreeProd(
159
- "license token uses unsupported alg \"${alg.ifEmpty { "(missing)" }}\", expected ES256",
160
- )
161
- }
162
-
163
- val kid = headerJson.optString("kid", "")
164
- if (kid.isEmpty()) {
165
- return LicenseStatus.FreeProd(
166
- "license token header missing kid; cannot select verification key",
167
- )
168
- }
169
-
170
- val jwk = publicKeys[kid]
171
- ?: return LicenseStatus.FreeProd(
172
- "license token kid \"$kid\" is not in the SDK's public-key " +
173
- "registry; either the key was rotated and you're on an old SDK, " +
174
- "or the token was signed with a key we don't recognise",
175
- )
176
-
177
- if (kid == PLACEHOLDER_KID && !allowPlaceholderKey) {
178
- return LicenseStatus.FreeProd(
179
- "license token signed with the placeholder key (kid \"$PLACEHOLDER_KID\"); " +
180
- "replace the placeholder in PublicKeys.kt with a real key generated " +
181
- "via scripts/license/generate-keypair.mjs before issuing real licenses",
182
- )
183
- }
184
-
185
- // Parse the JWT structure (header / payload / signature) with nimbus.
186
- val signed: SignedJWT = try {
187
- SignedJWT.parse(token)
188
- } catch (e: ParseException) {
189
- return LicenseStatus.FreeProd(
190
- "license token verification failed: ${e.message ?: "parse error"}",
191
- )
192
- }
193
-
194
- // Verify signature. Build the public ECKey from the JWK coordinate
195
- // strings (x / y). nimbus expects `Base64URL`-wrapped strings — they're
196
- // already base64url-encoded in the JWK, so we just rehydrate.
197
- val ecKey: ECKey = try {
198
- ECKey.Builder(Curve.P_256, Base64URL(jwk.x), Base64URL(jwk.y))
199
- .keyID(jwk.kid ?: kid)
200
- .build()
201
- } catch (e: Throwable) {
202
- return LicenseStatus.FreeProd(
203
- "license public-key entry is malformed: ${e.message ?: e.javaClass.simpleName}",
204
- )
205
- }
206
-
207
- try {
208
- if (signed.header.algorithm != JWSAlgorithm.ES256) {
209
- // Defense-in-depth — we already checked the header above,
210
- // but nimbus might surface its own opinion about the alg.
211
- return LicenseStatus.FreeProd(
212
- "license token uses unsupported alg \"${signed.header.algorithm}\", expected ES256",
213
- )
214
- }
215
- val verifier = ECDSAVerifier(ecKey)
216
- if (!signed.verify(verifier)) {
217
- return LicenseStatus.FreeProd(
218
- "license token signature did not verify against kid \"$kid\"; " +
219
- "the token may have been tampered with or was signed by a different key",
220
- )
221
- }
222
- } catch (e: JOSEException) {
223
- return LicenseStatus.FreeProd(
224
- "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
225
- )
226
- } catch (e: Throwable) {
227
- return LicenseStatus.FreeProd(
228
- "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
229
- )
230
- }
231
-
232
- // Coerce + validate the payload shape ourselves. nimbus' claim
233
- // set is loose-typed JSON; we want strict checks with specific
234
- // free-prod reasons.
235
- val claims = signed.jwtClaimsSet
236
- val payload = parsePayload(claims.toJSONObject())
237
- ?: return LicenseStatus.FreeProd(
238
- "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
239
- )
240
-
241
- // Issuer check.
242
- if (payload.iss != "DVAI-Bridge") {
243
- return LicenseStatus.FreeProd(
244
- "license token claim \"iss\" failed: expected \"DVAI-Bridge\", got \"${payload.iss}\"",
245
- )
246
- }
247
-
248
- // Expiry check. Surface "free-expired" specifically so the developer
249
- // knows whose renewal to chase.
250
- val nowSec = System.currentTimeMillis() / 1000
251
- if (payload.exp <= nowSec) {
252
- return LicenseStatus.FreeExpired(
253
- licensee = payload.licensee,
254
- expiredAt = payload.exp,
255
- )
256
- }
257
-
258
- // Platform check.
259
- if (!payload.platforms.contains(platform.wire)) {
260
- return LicenseStatus.FreeProd(
261
- "license token does not authorise platform \"${platform.wire}\"; " +
262
- "the token covers [${payload.platforms.joinToString(", ")}]",
263
- )
264
- }
265
-
266
- // Audience check.
267
- val matched = matchAudience(runtimeAudience, payload.aud)
268
- ?: return LicenseStatus.FreeProd(
269
- "license token's audience entries [${payload.aud.joinToString(", ")}] " +
270
- "do not match the current runtime audience \"${runtimeAudience ?: "(none)"}\"" +
271
- if (runtimeAudience == null) {
272
- " — set DVAI_AUDIENCE in your environment, or use a \"*\" aud entry for any-domain licenses"
273
- } else {
274
- ""
275
- },
276
- )
277
-
278
- return when (payload.tier) {
279
- "commercial" -> LicenseStatus.Commercial(
280
- licensee = payload.licensee,
281
- expiresAt = payload.exp,
282
- platform = platform,
283
- audienceMatched = matched,
284
- )
285
- "trial" -> LicenseStatus.Trial(
286
- licensee = payload.licensee,
287
- expiresAt = payload.exp,
288
- platform = platform,
289
- audienceMatched = matched,
290
- )
291
- else -> LicenseStatus.FreeProd(
292
- "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
293
- )
294
- }
295
- }
296
- }
297
-
298
- /* -------------------------------------------------------------------------- */
299
- /* Helpers */
300
- /* -------------------------------------------------------------------------- */
301
-
302
- /**
303
- * Parse the JWT payload into a [DvaiLicensePayload], or return null if any
304
- * required field is missing/malformed. Mirrors `isLicensePayload` on the JS side.
305
- */
306
- private fun parsePayload(claims: Map<String, Any?>): DvaiLicensePayload? {
307
- val iss = claims["iss"] as? String ?: return null
308
- val sub = claims["sub"] as? String ?: return null
309
- val tier = claims["tier"] as? String ?: return null
310
- if (tier != "commercial" && tier != "trial") return null
311
- val licensee = claims["licensee"] as? String ?: return null
312
-
313
- // `aud` can be either a JSON array (the canonical form) or a single
314
- // string (nimbus sometimes single-unwraps single-element audiences).
315
- // We coerce both into a List<String>.
316
- val audRaw = claims["aud"]
317
- val aud: List<String> = when (audRaw) {
318
- is List<*> -> audRaw.filterIsInstance<String>().also {
319
- if (it.size != audRaw.size) return null
320
- }
321
- is String -> listOf(audRaw)
322
- else -> return null
323
- }
324
-
325
- val platformsRaw = claims["platforms"] as? List<*> ?: return null
326
- val platforms = platformsRaw.filterIsInstance<String>()
327
- if (platforms.size != platformsRaw.size) return null
328
-
329
- // `iat` / `exp` are seconds-since-epoch integers in our JWTs, but
330
- // nimbus' parser may surface them as Date or Number. Coerce both.
331
- val iat = coerceEpochSeconds(claims["iat"]) ?: return null
332
- val exp = coerceEpochSeconds(claims["exp"]) ?: return null
333
-
334
- return DvaiLicensePayload(
335
- iss = iss,
336
- sub = sub,
337
- aud = aud,
338
- tier = tier,
339
- platforms = platforms,
340
- licensee = licensee,
341
- iat = iat,
342
- exp = exp,
343
- )
344
- }
345
-
346
- private fun coerceEpochSeconds(v: Any?): Long? = when (v) {
347
- is Number -> v.toLong()
348
- is Date -> v.time / 1000
349
- else -> null
350
- }
351
-
352
- /** Decode base64url-encoded JSON header to a UTF-8 string. */
353
- private fun base64UrlDecodeUtf8(s: String): String {
354
- val bytes = Base64.decode(s, Base64.URL_SAFE or Base64.NO_WRAP)
355
- return String(bytes, Charsets.UTF_8)
356
- }
357
-
358
- /**
359
- * Build the developer-facing error message for [LicenseRequiredError].
360
- * Intentionally verbose — mirrors the JS side's multi-line format with
361
- * Android-specific resolution paths.
362
- */
363
- internal fun buildRequiredErrorMessage(status: LicenseStatus): String {
364
- val header =
365
- "\n" +
366
- "DVAI-Bridge Commercial License Required\n" +
367
- "=======================================\n"
368
-
369
- val reason = when (status) {
370
- is LicenseStatus.FreeExpired ->
371
- "License for \"${status.licensee}\" expired at ${Date(status.expiredAt * 1000)}."
372
- is LicenseStatus.FreeProd -> status.reason
373
- else -> "(unknown status)"
374
- }
375
-
376
- val remediation =
377
- "\n" +
378
- "This SDK is licensed under BSL 1.1 and requires a valid commercial\n" +
379
- "or trial license to run in production / release builds.\n" +
380
- "\n" +
381
- "To resolve:\n" +
382
- " 1. Obtain a license at https://deepvoiceai.com/dvai-bridge/license\n" +
383
- " 2. Place the file at one of these locations (any will work):\n" +
384
- " - assets/dvai-license.jwt (bundled in the APK; auto-discovered)\n" +
385
- " - res/raw/dvai_license (bundled raw resource; auto-discovered)\n" +
386
- " - filesDir/dvai-license.jwt (internal storage; auto-discovered)\n" +
387
- " - the path you pass as StartOptions.licenseKeyPath\n" +
388
- " - the path in \$DVAI_LICENSE_PATH\n" +
389
- " - inline JWT in StartOptions.licenseToken or \$DVAI_LICENSE_TOKEN\n" +
390
- " 3. Re-run.\n" +
391
- "\n" +
392
- "Developing locally? The SDK auto-detects dev mode on:\n" +
393
- " - BuildConfig.DEBUG=true (pass hostBuildConfigDebug through StartOptions)\n" +
394
- " - apps installed with ApplicationInfo.FLAG_DEBUGGABLE\n" +
395
- " - DVAI_FORCE_DEV=1 environment variable (explicit override)\n" +
396
- "Any of these silences this error and lets the SDK run without a\n" +
397
- "license.\n"
398
-
399
- return header + "\n" + reason + "\n" + remediation
400
- }
1
+ package co.deepvoiceai.bridge.license
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import com.nimbusds.jose.JOSEException
6
+ import com.nimbusds.jose.JWSAlgorithm
7
+ import com.nimbusds.jose.crypto.ECDSAVerifier
8
+ import com.nimbusds.jose.jwk.Curve
9
+ import com.nimbusds.jose.jwk.ECKey
10
+ import com.nimbusds.jose.util.Base64URL
11
+ import com.nimbusds.jwt.SignedJWT
12
+ import kotlinx.coroutines.Dispatchers
13
+ import kotlinx.coroutines.withContext
14
+ import org.json.JSONObject
15
+ import java.text.ParseException
16
+ import java.util.Date
17
+
18
+ /**
19
+ * DVAI-Bridge license validator — offline JWT verification.
20
+ *
21
+ * Kotlin port of `packages/dvai-bridge-core/src/license/LicenseValidator.ts`.
22
+ * Verifies a JWT (header + payload + ECDSA P-256 signature) using
23
+ * `nimbus-jose-jwt`. The SDK ships only with public keys (see
24
+ * [DvaiPublicKeys]) and cannot itself produce valid licenses — so
25
+ * reverse-engineering the bundled APK gains nothing.
26
+ *
27
+ * Network calls: zero. The whole flow is offline by design — there's
28
+ * no "phone home" step, no license server polling, no DRM beacon.
29
+ *
30
+ * Android divergence from the JS validator: in production (non-DEBUG)
31
+ * Android builds, both [validateAndAssert] and the SDK's `start(...)`
32
+ * THROW [LicenseRequiredError] rather than falling back to a watermarked
33
+ * free-tier. This matches the iOS validator and the BSL 1.1 commercial
34
+ * enforcement story for native mobile distributions.
35
+ *
36
+ * @param context Application context (typically
37
+ * `applicationContext`). Required for audience
38
+ * detection (package name) and discovery
39
+ * (assets, raw resources, internal storage).
40
+ * @param token Pre-loaded JWT string. If non-null, skips all
41
+ * auto-discovery.
42
+ * @param path Explicit license file path. Overrides
43
+ * auto-discovery if non-null.
44
+ * @param hostBuildConfigDebug The host app's `BuildConfig.DEBUG` value. Pass
45
+ * this from `Application.onCreate()` so the
46
+ * validator can bypass enforcement on debug builds.
47
+ * Falls back to `ApplicationInfo.FLAG_DEBUGGABLE`
48
+ * if null.
49
+ * @param publicKeys Override for the public-key registry. Defaults
50
+ * to [DvaiPublicKeys.REGISTRY]. Tests inject
51
+ * their own keypair so they can sign + verify
52
+ * against a deterministic key without polluting
53
+ * the production registry.
54
+ * @param allowPlaceholderKey If true, accept tokens signed under
55
+ * [PLACEHOLDER_KID]. Off by default — a real
56
+ * production build must replace the placeholder
57
+ * with a generated key. Tests set this to true.
58
+ */
59
+ class LicenseValidator(
60
+ private val context: Context,
61
+ private val token: String? = null,
62
+ private val path: String? = null,
63
+ private val hostBuildConfigDebug: Boolean? = null,
64
+ private val publicKeys: Map<String, DvaiPublicKeyJwk> = DvaiPublicKeys.REGISTRY,
65
+ private val allowPlaceholderKey: Boolean = false,
66
+ ) {
67
+ /**
68
+ * Validate WITHOUT throwing. Returns a [LicenseStatus] describing what
69
+ * the validator determined; never throws on missing / invalid /
70
+ * expired licenses. Useful for host-app dashboards that want to
71
+ * display the licensee / expiry / fallback reason without halting
72
+ * SDK startup, and for tests.
73
+ *
74
+ * Idempotent; safe to call multiple times.
75
+ */
76
+ suspend fun validate(): LicenseStatus = withContext(Dispatchers.IO) {
77
+ // 1. Dev-mode bypass — license required only in production.
78
+ val dev = detectDevMode(context, hostBuildConfigDebug)
79
+ if (dev.isDev) {
80
+ return@withContext LicenseStatus.FreeDev(dev.reason)
81
+ }
82
+
83
+ // 2. Discover the token. Returns null when no license source is
84
+ // configured AND auto-discovery fails.
85
+ val discovered = discoverLicenseToken(
86
+ context,
87
+ LicenseDiscoveryOptions(token = token, path = path),
88
+ )
89
+ if (discovered == null) {
90
+ return@withContext LicenseStatus.FreeProd(
91
+ "no license token found; checked config.licenseToken, " +
92
+ "config.licenseKeyPath, DVAI_LICENSE_PATH env, " +
93
+ "DVAI_LICENSE_TOKEN env, assets/$DEFAULT_LICENSE_FILENAME, " +
94
+ "res/raw/$DEFAULT_LICENSE_RAW_RESOURCE_NAME, " +
95
+ "and filesDir/$DEFAULT_LICENSE_FILENAME",
96
+ )
97
+ }
98
+
99
+ // 3. Verify signature + claims with nimbus-jose-jwt.
100
+ verifyToken(discovered.token, detectPlatform(), detectAudience(context))
101
+ }
102
+
103
+ /**
104
+ * Strict validation entry point used by the SDK at startup. Returns
105
+ * [LicenseStatus] on success ([LicenseStatus.Commercial],
106
+ * [LicenseStatus.Trial], [LicenseStatus.FreeDev]) and THROWS
107
+ * [LicenseRequiredError] on [LicenseStatus.FreeProd] /
108
+ * [LicenseStatus.FreeExpired].
109
+ *
110
+ * This is the BSL 1.1 enforcement point: in production / release
111
+ * builds (any non-dev-mode environment), the SDK refuses to operate
112
+ * without a valid commercial or trial license. Developers running on
113
+ * debug builds / `BuildConfig.DEBUG=true` / explicit DVAI_FORCE_DEV
114
+ * are unaffected — those return a [LicenseStatus.FreeDev] status and
115
+ * the SDK proceeds normally.
116
+ *
117
+ * Use [validate] instead when you want to inspect the status without
118
+ * halting startup (host-app dashboards, test fixtures).
119
+ */
120
+ suspend fun validateAndAssert(): LicenseStatus {
121
+ val status = validate()
122
+ when (status) {
123
+ is LicenseStatus.FreeProd ->
124
+ throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
125
+ is LicenseStatus.FreeExpired ->
126
+ throw LicenseRequiredError(buildRequiredErrorMessage(status), status)
127
+ else -> return status
128
+ }
129
+ }
130
+
131
+ private fun verifyToken(
132
+ token: String,
133
+ platform: DvaiPlatform,
134
+ runtimeAudience: String?,
135
+ ): LicenseStatus {
136
+ // Parse the JWT structure first so we can read the header to pick
137
+ // the right public key. We could let nimbus iterate but specifying
138
+ // the key up-front gives clearer error messages on misses.
139
+ val parts = token.split(".")
140
+ if (parts.size != 3 || parts[0].isEmpty()) {
141
+ return LicenseStatus.FreeProd(
142
+ "license token is not a well-formed JWT (need 3 segments)",
143
+ )
144
+ }
145
+
146
+ val headerJson: JSONObject = try {
147
+ JSONObject(base64UrlDecodeUtf8(parts[0]))
148
+ } catch (e: Throwable) {
149
+ return LicenseStatus.FreeProd(
150
+ "license token header is not parseable JSON: ${e.message ?: e.javaClass.simpleName}",
151
+ )
152
+ }
153
+
154
+ val alg = headerJson.optString("alg", "")
155
+ if (alg != "ES256") {
156
+ // Refuse `alg: none` and any non-ES256 algorithm. Critical
157
+ // defense against the classic JWT algorithm-confusion vulnerability.
158
+ return LicenseStatus.FreeProd(
159
+ "license token uses unsupported alg \"${alg.ifEmpty { "(missing)" }}\", expected ES256",
160
+ )
161
+ }
162
+
163
+ val kid = headerJson.optString("kid", "")
164
+ if (kid.isEmpty()) {
165
+ return LicenseStatus.FreeProd(
166
+ "license token header missing kid; cannot select verification key",
167
+ )
168
+ }
169
+
170
+ val jwk = publicKeys[kid]
171
+ ?: return LicenseStatus.FreeProd(
172
+ "license token kid \"$kid\" is not in the SDK's public-key " +
173
+ "registry; either the key was rotated and you're on an old SDK, " +
174
+ "or the token was signed with a key we don't recognise",
175
+ )
176
+
177
+ if (kid == PLACEHOLDER_KID && !allowPlaceholderKey) {
178
+ return LicenseStatus.FreeProd(
179
+ "license token signed with the placeholder key (kid \"$PLACEHOLDER_KID\"); " +
180
+ "replace the placeholder in PublicKeys.kt with a real key generated " +
181
+ "via scripts/license/generate-keypair.mjs before issuing real licenses",
182
+ )
183
+ }
184
+
185
+ // Parse the JWT structure (header / payload / signature) with nimbus.
186
+ val signed: SignedJWT = try {
187
+ SignedJWT.parse(token)
188
+ } catch (e: ParseException) {
189
+ return LicenseStatus.FreeProd(
190
+ "license token verification failed: ${e.message ?: "parse error"}",
191
+ )
192
+ }
193
+
194
+ // Verify signature. Build the public ECKey from the JWK coordinate
195
+ // strings (x / y). nimbus expects `Base64URL`-wrapped strings — they're
196
+ // already base64url-encoded in the JWK, so we just rehydrate.
197
+ val ecKey: ECKey = try {
198
+ ECKey.Builder(Curve.P_256, Base64URL(jwk.x), Base64URL(jwk.y))
199
+ .keyID(jwk.kid ?: kid)
200
+ .build()
201
+ } catch (e: Throwable) {
202
+ return LicenseStatus.FreeProd(
203
+ "license public-key entry is malformed: ${e.message ?: e.javaClass.simpleName}",
204
+ )
205
+ }
206
+
207
+ try {
208
+ if (signed.header.algorithm != JWSAlgorithm.ES256) {
209
+ // Defense-in-depth — we already checked the header above,
210
+ // but nimbus might surface its own opinion about the alg.
211
+ return LicenseStatus.FreeProd(
212
+ "license token uses unsupported alg \"${signed.header.algorithm}\", expected ES256",
213
+ )
214
+ }
215
+ val verifier = ECDSAVerifier(ecKey)
216
+ if (!signed.verify(verifier)) {
217
+ return LicenseStatus.FreeProd(
218
+ "license token signature did not verify against kid \"$kid\"; " +
219
+ "the token may have been tampered with or was signed by a different key",
220
+ )
221
+ }
222
+ } catch (e: JOSEException) {
223
+ return LicenseStatus.FreeProd(
224
+ "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
225
+ )
226
+ } catch (e: Throwable) {
227
+ return LicenseStatus.FreeProd(
228
+ "license token verification failed: ${e.message ?: e.javaClass.simpleName}",
229
+ )
230
+ }
231
+
232
+ // Coerce + validate the payload shape ourselves. nimbus' claim
233
+ // set is loose-typed JSON; we want strict checks with specific
234
+ // free-prod reasons.
235
+ val claims = signed.jwtClaimsSet
236
+ val payload = parsePayload(claims.toJSONObject())
237
+ ?: return LicenseStatus.FreeProd(
238
+ "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
239
+ )
240
+
241
+ // Issuer check.
242
+ if (payload.iss != "DVAI-Bridge") {
243
+ return LicenseStatus.FreeProd(
244
+ "license token claim \"iss\" failed: expected \"DVAI-Bridge\", got \"${payload.iss}\"",
245
+ )
246
+ }
247
+
248
+ // Expiry check. Surface "free-expired" specifically so the developer
249
+ // knows whose renewal to chase.
250
+ val nowSec = System.currentTimeMillis() / 1000
251
+ if (payload.exp <= nowSec) {
252
+ return LicenseStatus.FreeExpired(
253
+ licensee = payload.licensee,
254
+ expiredAt = payload.exp,
255
+ )
256
+ }
257
+
258
+ // Platform check.
259
+ if (!payload.platforms.contains(platform.wire)) {
260
+ return LicenseStatus.FreeProd(
261
+ "license token does not authorise platform \"${platform.wire}\"; " +
262
+ "the token covers [${payload.platforms.joinToString(", ")}]",
263
+ )
264
+ }
265
+
266
+ // Audience check.
267
+ val matched = matchAudience(runtimeAudience, payload.aud)
268
+ ?: return LicenseStatus.FreeProd(
269
+ "license token's audience entries [${payload.aud.joinToString(", ")}] " +
270
+ "do not match the current runtime audience \"${runtimeAudience ?: "(none)"}\"" +
271
+ if (runtimeAudience == null) {
272
+ " — set DVAI_AUDIENCE in your environment, or use a \"*\" aud entry for any-domain licenses"
273
+ } else {
274
+ ""
275
+ },
276
+ )
277
+
278
+ return when (payload.tier) {
279
+ "commercial" -> LicenseStatus.Commercial(
280
+ licensee = payload.licensee,
281
+ expiresAt = payload.exp,
282
+ platform = platform,
283
+ audienceMatched = matched,
284
+ )
285
+ "trial" -> LicenseStatus.Trial(
286
+ licensee = payload.licensee,
287
+ expiresAt = payload.exp,
288
+ platform = platform,
289
+ audienceMatched = matched,
290
+ )
291
+ else -> LicenseStatus.FreeProd(
292
+ "license token payload missing required DVAI fields (tier/platforms/aud/licensee)",
293
+ )
294
+ }
295
+ }
296
+ }
297
+
298
+ /* -------------------------------------------------------------------------- */
299
+ /* Helpers */
300
+ /* -------------------------------------------------------------------------- */
301
+
302
+ /**
303
+ * Parse the JWT payload into a [DvaiLicensePayload], or return null if any
304
+ * required field is missing/malformed. Mirrors `isLicensePayload` on the JS side.
305
+ */
306
+ private fun parsePayload(claims: Map<String, Any?>): DvaiLicensePayload? {
307
+ val iss = claims["iss"] as? String ?: return null
308
+ val sub = claims["sub"] as? String ?: return null
309
+ val tier = claims["tier"] as? String ?: return null
310
+ if (tier != "commercial" && tier != "trial") return null
311
+ val licensee = claims["licensee"] as? String ?: return null
312
+
313
+ // `aud` can be either a JSON array (the canonical form) or a single
314
+ // string (nimbus sometimes single-unwraps single-element audiences).
315
+ // We coerce both into a List<String>.
316
+ val audRaw = claims["aud"]
317
+ val aud: List<String> = when (audRaw) {
318
+ is List<*> -> audRaw.filterIsInstance<String>().also {
319
+ if (it.size != audRaw.size) return null
320
+ }
321
+ is String -> listOf(audRaw)
322
+ else -> return null
323
+ }
324
+
325
+ val platformsRaw = claims["platforms"] as? List<*> ?: return null
326
+ val platforms = platformsRaw.filterIsInstance<String>()
327
+ if (platforms.size != platformsRaw.size) return null
328
+
329
+ // `iat` / `exp` are seconds-since-epoch integers in our JWTs, but
330
+ // nimbus' parser may surface them as Date or Number. Coerce both.
331
+ val iat = coerceEpochSeconds(claims["iat"]) ?: return null
332
+ val exp = coerceEpochSeconds(claims["exp"]) ?: return null
333
+
334
+ return DvaiLicensePayload(
335
+ iss = iss,
336
+ sub = sub,
337
+ aud = aud,
338
+ tier = tier,
339
+ platforms = platforms,
340
+ licensee = licensee,
341
+ iat = iat,
342
+ exp = exp,
343
+ )
344
+ }
345
+
346
+ private fun coerceEpochSeconds(v: Any?): Long? = when (v) {
347
+ is Number -> v.toLong()
348
+ is Date -> v.time / 1000
349
+ else -> null
350
+ }
351
+
352
+ /** Decode base64url-encoded JSON header to a UTF-8 string. */
353
+ private fun base64UrlDecodeUtf8(s: String): String {
354
+ val bytes = Base64.decode(s, Base64.URL_SAFE or Base64.NO_WRAP)
355
+ return String(bytes, Charsets.UTF_8)
356
+ }
357
+
358
+ /**
359
+ * Build the developer-facing error message for [LicenseRequiredError].
360
+ * Intentionally verbose — mirrors the JS side's multi-line format with
361
+ * Android-specific resolution paths.
362
+ */
363
+ internal fun buildRequiredErrorMessage(status: LicenseStatus): String {
364
+ val header =
365
+ "\n" +
366
+ "DVAI-Bridge Commercial License Required\n" +
367
+ "=======================================\n"
368
+
369
+ val reason = when (status) {
370
+ is LicenseStatus.FreeExpired ->
371
+ "License for \"${status.licensee}\" expired at ${Date(status.expiredAt * 1000)}."
372
+ is LicenseStatus.FreeProd -> status.reason
373
+ else -> "(unknown status)"
374
+ }
375
+
376
+ val remediation =
377
+ "\n" +
378
+ "This SDK is licensed under BSL 1.1 and requires a valid commercial\n" +
379
+ "or trial license to run in production / release builds.\n" +
380
+ "\n" +
381
+ "To resolve:\n" +
382
+ " 1. Obtain a license at https://deepvoiceai.com/dvai-bridge/license\n" +
383
+ " 2. Place the file at one of these locations (any will work):\n" +
384
+ " - assets/dvai-license.jwt (bundled in the APK; auto-discovered)\n" +
385
+ " - res/raw/dvai_license (bundled raw resource; auto-discovered)\n" +
386
+ " - filesDir/dvai-license.jwt (internal storage; auto-discovered)\n" +
387
+ " - the path you pass as StartOptions.licenseKeyPath\n" +
388
+ " - the path in \$DVAI_LICENSE_PATH\n" +
389
+ " - inline JWT in StartOptions.licenseToken or \$DVAI_LICENSE_TOKEN\n" +
390
+ " 3. Re-run.\n" +
391
+ "\n" +
392
+ "Developing locally? The SDK auto-detects dev mode on:\n" +
393
+ " - BuildConfig.DEBUG=true (pass hostBuildConfigDebug through StartOptions)\n" +
394
+ " - apps installed with ApplicationInfo.FLAG_DEBUGGABLE\n" +
395
+ " - DVAI_FORCE_DEV=1 environment variable (explicit override)\n" +
396
+ "Any of these silences this error and lets the SDK run without a\n" +
397
+ "license.\n"
398
+
399
+ return header + "\n" + reason + "\n" + remediation
400
+ }