Kami

卡密开放接口 · 接入文档

给接入方(买卡密的人 / 你自己的软件客户端)看。服务端实现在

app/src/routes/open/license.ts 与 app/src/services/open-license.ts。


0. 三十秒版

POST https://<你的域名>/api/v1/open/license/activate   ← 首次激活,拿到 sessionId + licenseToken
POST https://<你的域名>/api/v1/open/license/verify     ← 每次启动 / 定时心跳
POST https://<你的域名>/api/v1/open/license/unbind     ← 换机前解绑
GET  https://<你的域名>/api/v1/open/notice             ← 拉应用公告,同时计一次启动
GET  https://<你的域名>/api/v1/open/vars               ← 读应用云变量(远端配置,可多条)
GET  https://<你的域名>/api/v1/open/ping               ← 排障探针

三个接口都要带 4 个头:

头值
x-kami-key应用接入密钥 ak_...(商户后台「应用列表」每张卡片上可见)
x-kami-timestamp当前时间,epoch 毫秒
x-kami-nonce一次性随机串,8-64 字符(每次请求换新的)
x-kami-signature见下面第 2 节

签名密钥 sk_... 只在你的后端 / 构建机里用,绝不能打进客户端二进制。

客户端只做两件不需要密钥的事:把 code + 机器码发给你的后端,或者直接调接口但只带签名(见 4.2 的推荐架构)。


1. 数据流

用户输入卡密
   │
   ├─ 你的客户端 ──→ 你的服务端(推荐:在这里持 sk_ 密钥签名)
   │                      │
   │                      └──→ Kami /open/license/activate
   │                                └──→ { sessionId, licenseToken, expiresAt }
   │
   └─ 或者客户端直接调(需内置 sk_,不推荐,见 6.4)

licenseToken 是一个 HMAC 签名的凭证,客户端可以离线验签 + 读到期时间,

所以不必每次启动都联网 —— 但每隔 renewAfter - now 必须联网续期一次(默认 12 小时)。


2. 签名算法(照抄即可)

2.1 规范化 body

先把 body 对象按键名递归排序、去掉 undefined,再序列化。这样换个字段顺序签名不会变。

function canonicalize(v: unknown): string {
  if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'
  if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`
  return `{${Object.entries(v as Record<string, unknown>)
    .filter(([, x]) => x !== undefined)
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, x]) => `${JSON.stringify(k)}:${canonicalize(x)}`)
    .join(',')}}`
}

2.2 待签串

METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH

2.3 签名

x-kami-signature = base64url_hmac_sha256(licenseSecret, 待签串)

base64url = 标准 base64 把 + 换 -、/ 换 _、去掉末尾 =。

2.4 参考实现

TypeScript / Node / Bun

import { createHmac } from 'node:crypto'

const b64url = (s: string) =>
  createHmac('sha256', 'body').update(s).digest('base64url')

const hmac = (key: string, msg: string) =>
  createHmac('sha256', key).update(msg, 'utf8').digest('base64url')

export function sign(secret: string, method: string, path: string, body: unknown) {
  const ts = String(Date.now())
  const nonce = crypto.randomUUID().replaceAll('-', '')
  const payload = [method, path, ts, nonce, b64url(canonicalize(body))].join('\n')
  return { ts, nonce, signature: hmac(secret, payload) }
}

Python

import base64, hashlib, hmac, json, time, uuid

def _b64(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).decode().rstrip("=")

def _canon(v) -> str:
    if v is None or not isinstance(v, (dict, list)):
        return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
    if isinstance(v, list):
        return "[" + ",".join(_canon(x) for x in v) + "]"
    items = sorted((k, x) for k, x in v.items() if x is not None)
    return "{" + ",".join(json.dumps(k) + ":" + _canon(x) for k, x in items) + "}"

def _hmac(key: str, msg: str) -> str:
    return _b64(hmac.new(key.encode(), msg.encode(), hashlib.sha256).digest())

def sign(secret: str, method: str, path: str, body: dict):
    ts = str(int(time.time() * 1000))
    nonce = uuid.uuid4().hex
    payload = "\n".join([method, path, ts, nonce, _hmac("body", _canon(body))])
    return {"x-kami-timestamp": ts, "x-kami-nonce": nonce,
            "x-kami-signature": _hmac(secret, payload)}

Go

func sign(secret, method, path string, body any) (ts, nonce, sig string) {
    ts = strconv.FormatInt(time.Now().UnixMilli(), 10)
    nonce = uuid.NewString()
    bodyHash := b64(hmacSHA256("body", canon(body)))
    payload := strings.Join([]string{method, path, ts, nonce, bodyHash}, "\n")
    return ts, nonce, b64(hmacSHA256(secret, payload))
}

canon 与上面两个实现同理:map 的 key 排序、剔除 nil、数组保序。


3. 接口详情

所有接口都返回同一层外壳:

{ "code": 0, "message": "激活成功", "data": { "...": "见下" } }

code:0 成功;4xx 见第 5 节。业务结论在 data.verdict。

3.1 POST /api/v1/open/license/activate

请求:

{
  "code": "VIP-GF7G-5HTH-SHBQ-BHP7",
  "machineCode": "HW-doc-demo-01",
  "deviceId": "可选,你的设备编号",
  "version": "1.2.0"
}

成功响应(真实样本):

{
  "code": 0,
  "message": "激活成功",
  "data": {
    "ok": true,
    "verdict": "ok",
    "message": "激活成功",
    "serverTime": 1794125118712,
    "nonce": "X_ngZMrppjOiflFn",
    "data": {
      "codeMasked": "VIP-********BHP7",
      "app": "前缀演示 4107",
      "kind": "月卡",
      "durationDays": 30,
      "expiresAt": 1796717118669,
      "remainingMs": 2591999957,
      "status": "active",
      "sessionId": "cmw7jrtgp0000isk8f3o7a5r2",
      "licenseToken": "v1.eyJsaWQ...hkeM2oC2FzoQGAe3k29cGtL1wRn_zHjRG89TjruyDsE",
      "cardKey": "b29ba19ca4705f3b417c9c64d083edfa461655a8f9d1abb500269e06959d1444",
      "renewAfter": 1794168318712,
      "notice": null,
      "updateNotice": null,
      "currentVersion": null,
      "minVersion": null,
      "forceUpdate": false
    }
  }
}
字段用法
sessionId之后调 /verify、/unbind 的凭据,存内存或加密存盘
licenseToken离线凭证,见第 4 节
cardKey验 licenseToken 用的密钥(每张卡一个),只在这里和 /verify 里下发
expiresAt到期时间(epoch 毫秒),null = 永久卡
renewAfter到这个时间前必须联网续期一次
forceUpdatetrue 时客户端应拦在更新页

3.2 POST /api/v1/open/license/verify

{ "sessionId": "cmw7...", "machineCode": "HW-doc-demo-01", "version": "1.2.0" }

sessionId 丢了就传 code(会重新按卡密查)。返回结构与 activate 相同,cardKey 会再下发一次。

verdict 为 ok 时放行;其他值一律按 data.message 提示用户。

组织黑名单(activate / verify 都会先过这道闸)

商户如果建了组织(或加入了别人的组织),组织里有一份公共黑名单,条目分三类:

type写法说明
machineA7F3-2C91-B8DE-65A0机器码,大小写不敏感、忽略首尾空白
ip198.51.100.7单个 IP,IPv4 / IPv6 都行
ip_range203.0.113.0/24、203.0.113.0-203.0.113.255、203.0.113.*CIDR、闭区间、尾段通配都认

命中逻辑在服务端:activate 和 verify 拿你传的 machineCode 和请求源 IP(x-forwarded-for 第一跳)去比对该应用所属商户所在组织的生效条目,命中就直接返回

{ "code": 0, "message": "该设备或网络已被封禁,请联系卖家",
  "data": { "ok": false, "verdict": "org_blocked" } }

这时不要重试、不要自动解绑换机 —— 换机也还在名单里。给用户一条明确出路:联系卖家申诉,卖家在组织里删掉条目后立刻恢复(服务端缓存 5 秒)。

成员提交的条目要创建者批准才生效,创建者自己提交的直接生效;所以「刚加的条目没拦住」通常是还在待批准。

3.3 POST /api/v1/open/license/unbind

{ "sessionId": "cmw7...", "machineCode": "HW-doc-demo-01" }

成功:

{ "code": 0, "message": "已解绑,可以换机激活",
  "data": { "ok": true, "verdict": "ok",
            "data": { "codeMasked": "VIP-********BHP7", "cooldownMs": 0, "deducted": false } } }

cooldownMs > 0 表示冷却期未过,此时 verdict 是 rate_limited,message 里带还剩几小时。

3.4 GET /api/v1/open/ping

curl https://<域名>/api/v1/open/ping -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "服务正常",
  "data": { "ok": true, "app": "前缀演示 4107", "serverTime": 1794124725849,
            "proof": "V1RY2DaiJE7m4LtEh8XD1iGAOrYAXUH7gDmcXCOO7EA",
            "clockSkewToleranceMs": 120000 } }

自检密钥对不对:proof == base64url(HMAC(licenseSecret, "PING\n" + serverTime))。

3.5 GET /api/v1/open/notice

拉取商户在后台发布的公告,并顺带记一次应用启动。只需要接入密钥,不需要签名四件套 ——

公告内容本来就是给所有终端用户看的,签名不增加任何保护。

每个应用只有一条公告:商户改了就是覆盖这一条,客户端拿到的永远是当前有效的那条;

要下架就让商户把「对客户端可见」关掉。

curl https://<域名>/api/v1/open/notice -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "ok",
  "data": {
    "app": { "name": "Lumen 阅读器", "currentVersion": "2.4.1", "minVersion": "2.0.0" },
    "tips": { "notice": "本周末维护,略有卡顿", "updateNotice": "修复了夜间模式闪烁" },
    "notices": [
      { "id": 2, "appId": 2, "title": "今晚 23:00 停机维护",
        "content": "预计 30 分钟,期间无法登录。",
        "level": "warning", "visible": true, "pinned": true,
        "publishedAt": "2026-11-13T16:33:40.376Z" }
    ],
    "launches": 128, "day": "2026-11-14",
    "serverTime": 1794587533935, "requestId": "c0178e29181980d6"
  } }

字段说明:

字段含义
notices数组,0 或 1 项:商户写着公告且 visible=true 时才有;按列表遍历的写法不用改
notices[].levelinfo 普通 / warning 提醒 / critical 重要,客户端可据此配色
notices[].pinned商户勾了「重点标记」,适合登录前强弹
tips旧版字段兼容:商户早期在应用配置里写的「应用公告 / 更新公告」原文
launches该应用的累计启动次数(含本次)
day本次计数落到的自然日(东八区),与后台「今日」一致

计数规则:

客户端建议:启动时先请求它,notices[0] 存在就展示(pinned 或 critical 的弹在登录前);

请求失败(离线、超时)不要阻塞启动,公告是提示不是许可证。


3.6 GET /api/v1/open/vars

读商户在后台设置的云变量 —— 一个应用可以有多条,每条一个名字(key),

客户端拿到的是一张扁平 map。同样只需要接入密钥,不需要签名:这些值本来就要下发给所有终端用户。

curl https://<域名>/api/v1/open/vars -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "ok",
  "data": {
    "app": { "name": "夜行工具箱" },
    "variables": {
      "beta": true,
      "features": { "promo": true, "channels": ["web", "app"] },
      "maxTabs": 6,
      "theme": "night"
    },
    "count": 4,
    "updatedAt": "2026-11-13T17:03:52.953Z",
    "serverTime": 1794589433049, "requestId": "fa17b21df9ceef29"
  } }

没设置时 variables 是空对象,不是 404:

{ "code": 0, "message": "ok",
  "data": { "app": { "name": "灰盒 · 颜色提取" }, "variables": {},
            "count": 0, "updatedAt": null,
            "serverTime": 1794589433089, "requestId": "ba001c05a273c9d8" } }

所以客户端要写成「读到了就覆盖内置默认值」,空对象不是失败:

const defaults = { theme: 'day', maxTabs: 3, beta: false, features: {} }

const res = await fetch(`${BASE}/open/vars`, { headers: { 'x-kami-key': KEY } })
const { variables } = (await res.json()).data
const config = { ...defaults, ...variables }   // variables 里的值已经是原生类型

字段说明:

字段含义
variables扁平的 名字 → 值 对象;值已按商户选的类型还原:number 是数字、boolean 是布尔、json 是对象、string 是字符串
variables[key] 的类型由商户在后台逐条指定,"3.5" 与 3.5 不会混淆
count变量条数,省得自己 Object.keys().length
updatedAt这批变量里最后一次保存时间(UTC ISO);一条都没有时是 null
app.name应用名,多应用共用一个客户端时用来确认 key 没拿错

四种类型混在一张表里,值各自保持类型:

"variables": {
  "changelog": "https://kami.example.com/changelog",   // string
  "maxTabs": 3.5,                                       // number,不是 "3.5"
  "beta": true,                                         // boolean
  "features": { "theme": "night", "items": [1, 2] }     // json,数组可以当字段值
}

限制(服务端会校验,不合法直接 400):

要存列表就包一层:{ "items": [ ... ] },数组作为字段值是可以的。

客户端建议:

否则任何拿到 ak_ 的人都能改你的线上配置。


3.7 统计口径(客户端无需调用)

商户后台的「数据统计」只看卡密,不额外埋点:

指标来源口径
卡密用户分布License.activatedIp 首次激活时的 IP一张卡密只算一次,精确到省
新增激活卡密LicenseEvent(kind=activate)同一张卡密的首个 activate 记录,换机重装不重复计
激活请求LicenseEvent(kind=activate) 全量含换机、同机重装导致的重放
覆盖省级地区有激活卡密的省 / 自治区 / 直辖市 / 特别行政区上限 34

IP 归属解析链:内网 / 回环 / CGNAT → 本机/内网;GeoCN.mmdb → 省级;其后用 APNIC 段表兜底港澳台;仍未命中记 境外。

归属地只在首次激活时算一次,写进 License.activatedIp / activatedRegionCode / activatedRegionName,之后统计全部是本地聚合,开放接口不因为统计多一次写。

留存:统计数据最多保留 90 天,面板只提供 7 / 30 / 90 天三档。服务启动时清一次、之后每 24 小时清一次 LicenseEvent 里超过 90 天的记录;卡密本身(License)与归属地不清。解绑冷却只看最近一次 unbind,不受清理影响。

4. 客户端如何做到「被破解也跑不起来」

这是整套设计的重点,四层,缺一层都能被绕过:

4.1 服务端是唯一权威

到期时间、绑定机器、是否停用,全部由服务端在 /verify 时判定。

客户端不能参与计算 —— 用户改本机时间、改返回值、改配置里的到期日,对 /verify 的结果没影响。

body 里塞 expiresAt 这类未知字段会被 schema 拒掉(422),塞进去也不会被读。

4.2 离线凭证用签名,不用加密

licenseToken = "v1." + base64url(payload) + "." + base64url(HMAC-SHA256(payload, cardKey))
payload = { lid, app, sid, mc, exp, iat, nbf, days }

客户端本地校验:

1. 拆三段,验 sig == HMAC(cardKey, "v1." + body)

2. payload.mc == 本机机器码(必须验,否则复制 token 到别的机器就能用)

3. payload.exp == 0 || now < exp * 1000

4. now < payload.nbf * 1000(离线宽限期内)

改任何一项,签名都不对。不要把 cardKey 写死进代码 —— 每张卡不同,

泄露一张不影响别人;也不要把它写进日志。

4.3 建议的架构(真正防破解的那个)

客户端 ──(你的私有协议)──→ 你的服务端 ──(HMAC 签名)──→ Kami

sk_ 密钥只在你服务端。客户端即使被完全逆向,也只能「重放你服务端的合法响应」——

而响应绑定了 nonce,每次不同,重放直接 401。

如果反过来把 sk_ 打进客户端,逆向者拿到密钥就能自己签「激活成功」,这套就白做了。

所以:单机软件也要有一个薄服务端(哪怕是 Cloudflare Worker)。

4.4 响应也要验

服务端每个响应都带 x-kami-response-signature:

x-kami-response-signature = base64url(HMAC(licenseSecret,
    "RESPONSE\n" + 请求的 nonce + "\n" + code + "\n" + base64url(HMAC("body", canonicalize(data)))))

客户端验一下,中间人就没法把 {"verdict":"code_expired"} 改写或伪造。

x-kami-server-time 头顺便帮你校时。


5. 错误码与 verdict

HTTP 层

code含义处理
0成功——
401缺 x-kami-key / 签名错 / 重放 / 时间偏差检查密钥与算法;时间偏差时用 x-kami-server-time 校准后重试
404应用不存在或已下架检查 apiKey
422参数不合法(长度、类型)看 message 里的字段名
429限流按 x-open-detail.retryAfter 秒重试

verdict(data.verdict,HTTP 200,看这个决定放行)

verdict含义客户端该做什么
ok通过放行
code_invalid卡密不存在 / 格式错提示「卡密无效」,别回显用户输入
code_disabled被商户停用提示「该卡密已停用」
code_expired已过期提示续费
device_mismatch / machine_conflict不是绑定机器提示换回原机器,或走解绑
org_blocked命中所属组织的公共黑名单(机器码 / IP / IP 段)直接拒绝登录,提示联系卖家;不要自动重试
session_revoked会话被吊销(解绑/换机)重新 activate
clock_skew本机时间与服务器差太多用 serverTime 校准后重试
replaynonce 重复你代码里的错误:每次请求必须新 nonce
rate_limited限流稍后重试 / 显示冷却剩余
server_error服务端内部问题宁可放行一次,别当盗版处理,否则你自己掉用户

6. 安全边界(务必读)

6.1 已经防住的

6.2 需要你配合的

6.3 已知限制(不藏着)

否则 A 实例发的 nonce 在 B 实例上不认,会出现偶发 replay。

OPEN_OFFLINE_GRACE_MS,但也别太长 —— 那是盗版能够「拔网线白嫖」的窗口。

要更强的绑定需要上设备证书(每个客户端的私钥签名),那是另一个工程量级。


7. 快速自测

KEY=ak_你的接入密钥
SECRET=sk_你的签名密钥
TS=$(date +%s000)
NONCE=$(openssl rand -hex 12)
BODY='{"code":"VIP-XXXX-XXXX-XXXX-XXXX","machineCode":"HW-test-0001"}'

# body hash 要用规范化 JSON,简单例子(字段已按字母序、无空格)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'body' -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
SIG=$(printf 'POST\n/api/v1/open/license/activate\n%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

curl -s -X POST https://<域名>/api/v1/open/license/activate \
  -H "content-type: application/json" \
  -H "x-kami-key: $KEY" -H "x-kami-timestamp: $TS" \
  -H "x-kami-nonce: $NONCE" -H "x-kami-signature: $SIG" \
  -d "$BODY"

8. 服务端实现索引

文件作用
app/src/routes/open/license.ts路由、签名校验、nonce 去重、响应签名
app/src/services/open-license.ts激活 / 校验 / 解绑逻辑、审计
app/src/routes/open/cloud-var.ts云变量只读接口(GET /open/vars)、商户写入接口
app/src/services/app-cloud-var.ts云变量类型校验、规范化、类型还原
app/src/utils/signature.ts规范化、HMAC、token 签发与解析
app/src/types/open/types.tsverdict 常量、限制、宽限期
app/prisma/migrations/20261108075543_open_api_licensing/LicenseSession / LicenseEvent 表
商户后台「应用列表」每张卡片上可看 / 复制 / 轮换两把密钥
app/src/services/organization.ts组织、邀请、公共黑名单与命中查询
app/src/utils/ip.tsIP / CIDR / 区间 / 通配的归一化与匹配
app/src/utils/geo.tsIP 归属地:GeoCN.mmdb + APNIC 港澳台段表 + 内网判定
app/src/services/app-stats.ts激活归属回填、统计面板取数
app/src/routes/merchant/stats.ts商户统计面板接口 GET /api/v1/merchant/stats
app/prisma/migrations/20261114025906_stats_geo/License 归属地字段(同批建的 AppRequestStat 已在 20261114043000_drop_request_stats 中移除)