// Authsy SDK for Kotlin / Android. Requires OkHttp + org.json (bundled on Android). // // val client = AuthsyClient(Authsy.Options( // baseUrl = "https://YOUR-HOST/api/v1", // apiKey = "ak_live_xxx", // signingSecret = "…" // portal -> Settings; verifies responses // )) // client.activate(licenseKey, machineId) // val ok = client.validate(licenseKey, machineId) // // Responses are HMAC-SHA256 signed over canonical JSON; validate() verifies the // signature, echoes a per-call nonce and rejects stale responses. Run network // calls off the main thread (Dispatchers.IO). iOS: mirror this with URLSession // + CryptoKit (HMAC) and the same canonical() recipe. package app.authsy.sdk import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.security.MessageDigest import java.util.UUID import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec class AuthsyException(message: String, val code: String? = null) : Exception(message) class AuthsyClient(private val opts: Options) { data class Options( val baseUrl: String, val apiKey: String, val signingSecret: String? = null, val maxSkewMs: Long = 5 * 60 * 1000, val http: OkHttpClient = OkHttpClient() ) private val base = opts.baseUrl.trimEnd('/') var lastResponse: JSONObject? = null private set init { if (opts.baseUrl.isEmpty()) throw AuthsyException("baseUrl is required") if (opts.apiKey.isEmpty()) throw AuthsyException("apiKey is required") } companion object { /** Canonical JSON: object keys sorted lexicographically at every level. */ fun canonical(v: Any?): String = when (v) { is JSONObject -> v.keys().asSequence().sorted().joinToString(",", "{", "}") { k -> JSONObject.quote(k) + ":" + canonical(v.get(k)) } is JSONArray -> (0 until v.length()).joinToString(",", "[", "]") { canonical(v.get(it)) } is String -> JSONObject.quote(v) null, JSONObject.NULL -> "null" is Boolean -> v.toString() else -> v.toString() } } private fun hmacHex(message: String): String { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(opts.signingSecret!!.toByteArray(), "HmacSHA256")) return mac.doFinal(message.toByteArray()).joinToString("") { "%02x".format(it) } } fun verifySignature(resp: JSONObject): Boolean { val secret = opts.signingSecret ?: throw AuthsyException("signingSecret required to verify responses") val sig = resp.optString("signature") val clone = JSONObject(resp.toString()).apply { remove("signature") } val expected = hmacHex(canonical(clone)) return MessageDigest.isEqual(expected.toByteArray(), sig.toByteArray()) } private fun post(path: String, body: JSONObject): JSONObject { val req = Request.Builder() .url("$base$path") .addHeader("X-Api-Key", opts.apiKey) .post(body.toString().toRequestBody("application/json".toMediaType())) .build() opts.http.newCall(req).execute().use { res -> val raw = res.body?.string() ?: "" if (res.code == 403) throw AuthsyException("invalid api key", "invalid_api_key") if (res.code == 429) throw AuthsyException("over quota", "over_quota") if (raw.isEmpty()) throw AuthsyException("empty response (${res.code})") return JSONObject(raw) } } private fun checked(resp: JSONObject, nonce: String?): JSONObject { if (opts.signingSecret != null) { if (!verifySignature(resp)) throw AuthsyException("response signature invalid (tampered?)", "bad_signature") if (nonce != null && resp.optString("nonce") != nonce) throw AuthsyException("nonce mismatch (replay?)", "nonce_mismatch") if (resp.has("ts") && Math.abs(System.currentTimeMillis() - resp.getLong("ts")) > opts.maxSkewMs) throw AuthsyException("response is stale", "stale") } return resp } private fun nonce() = UUID.randomUUID().toString() fun activate(licenseKey: String, machineId: String): JSONObject { val n = nonce() val body = JSONObject().put("license_key", licenseKey).put("machine_id", machineId).put("nonce", n) return checked(post("/client/activate", body), n) } fun validate(licenseKey: String, machineId: String): Boolean { val n = nonce() val body = JSONObject().put("license_key", licenseKey).put("machine_id", machineId).put("nonce", n) val resp = checked(post("/client/validate", body), n) lastResponse = resp return resp.optBoolean("valid") } }