【Android安全-安卓设备指纹的正确使用方式(Leona)】此文章归类为:Android安全。
Leona.sense() 到 /v1/verdict 的可落地闭环:签名、落库、错误处理与回归验证(基于公开示例)/v1/verdict(公开合约)是后端接口:必须用 SecretKey 签名请求;并明确标注为 single-use,因此后端落库/缓存是硬要求。examples/boxid-verdict/(Python/Java/Go/Node/C/C++)。本文把这些公开事实串成一条能直接照着做的最小闭环,并给出可复制的测试向量。移动对抗安全的工程化落地,关键不是端上堆更多检测点,而是把链路做对:端上只上报证据并返回 BoxId;后端用 SecretKey + 请求签名调用 /v1/verdict 换证据报告并落库;业务策略在后端输出 allow/challenge/deny,并留下可审计的证据快照。本文只基于公开仓库内容(README + examples/boxid-verdict),讲清楚合约、签名、single-use 语义、落库结构、错误处理与回归验证。
/v1/verdict。/v1/verdict 在公开 README 中明确标注为 single-use。Leona.sense() 上报证据 → 得到 BoxIdBoxIdPOST /v1/verdict(SecretKey + 签名)换取证据报告这条链路的本质是:把“最终决策出口”从 APK 移出,并把解释性与可治理性放到后端。
/v1/verdict 公开合约(后端调用)Endpoint:
POST 39eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9k6h3!0F1j5g2)9J5k6i4S2A6P5h3q4F1M7$3S2S2L8W2)9J5k6h3y4G2L8g2)9J5c8Y4j5I4i4K6u0r3N6X3g2J5k6r3W2U0N6l9`.`.Body:
{"boxId":"01KR0000000000000000000000"}
Headers(后端构造):
Authorization: Bearer <LEONA_SECRET_KEY>Content-Type: application/jsonX-Leona-Timestamp: <unix-time-ms>X-Leona-Nonce: <random-nonce>X-Leona-Signature: <base64url-hmac-sha256>签名算法(公开说明):
signingText = timestamp + "\n" + nonce + "\n" + sha256(requestBody)
signature = base64url_no_padding(HMAC-SHA256(secretKey, signingText))
工程化解读:
nonce + timestamp 防重放;body sha256 防篡改。下面代码片段来自公开仓库 examples/boxid-verdict/,你可以用它们作为后端集成最小参考实现。
examples/boxid-verdict/nodejs/query_boxid.mjs)import crypto from "node:crypto";
const body = JSON.stringify({ boxId });
const timestamp = Date.now().toString();
const nonce = crypto.randomBytes(16).toString("base64url");
const bodySha256 = crypto.createHash("sha256").update(body).digest("hex");
const signingText = `${timestamp}\n${nonce}\n${bodySha256}`;
const signature = crypto.createHmac("sha256", secret).update(signingText).digest("base64url");
examples/boxid-verdict/python/query_boxid.py)body = json.dumps({"boxId": box_id}, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time() * 1000))
nonce = base64url_no_padding(secrets.token_bytes(16))
body_sha256 = hashlib.sha256(body).hexdigest()
signing_text = f"{timestamp}\n{nonce}\n{body_sha256}".encode("utf-8")
signature = base64url_no_padding(hmac.new(secret.encode("utf-8"), signing_text, hashlib.sha256).digest())
examples/boxid-verdict/go/query_boxid.go)body, _ := json.Marshal(map[string]string{"boxId": boxID})
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
nonce := base64.RawURLEncoding.EncodeToString(randomBytes(16))
bodyHash := sha256.Sum256(body)
signingText := fmt.Sprintf("%s\n%s\n%s", timestamp, nonce, hex.EncodeToString(bodyHash[:]))
signature := base64.RawURLEncoding.EncodeToString(hmacSha256([]byte(secret), []byte(signingText)))
说明:三种语言虽然写法不同,但签名输入字符串完全一致:timestamp\nnonce\nsha256(body)。
很多集成问题不是“服务端不稳定”,而是签名实现细节不一致(JSON 是否有空格、是否 base64url、是否去 padding、timestamp 单位等)。
建议你先做一个“固定输入 → 固定输出”的单元测试,把签名实现锁死。
secretKey = "sk_test_123"timestamp = "1700000000000"nonce = "nonce_test"body = {"boxId":"01KR0000000000000000000000"}(注意:JSON 必须无多余空格)计算结果:
body_sha256_hex = 1d6530c70bc08d977158e83fb8fc5a11ef08490cabfdec17fa54f392e4754a45signature_base64url = _Yjj8KhRfxMEnGaoRJrRKNtp_LOO2pNi2ndTzh9bLhsimport crypto from "node:crypto";
function sign(secret, timestamp, nonce, body) {
const bodySha256 = crypto.createHash("sha256").update(body).digest("hex");
const signingText = `${timestamp}\n${nonce}\n${bodySha256}`;
return crypto.createHmac("sha256", secret).update(signingText).digest("base64url");
}
const body = '{"boxId":"01KR0000000000000000000000"}';
const sig = sign("sk_test_123", "1700000000000", "nonce_test", body);
if (sig !== "_Yjj8KhRfxMEnGaoRJrRKNtp_LOO2pNi2ndTzh9bLhs") throw new Error("signature mismatch");
只要这个测试过了,你再去联调线上接口,定位问题会简单很多。
公开 README 明确:/v1/verdict 是 single-use,成功查询会消费 BoxId。
这会直接影响你的业务实现:
boxIddeviceFingerprintcanonicalDeviceIdeventsauthoritativeRiskTags / telemetryRiskTagsriskTagsBySourceprovenance、policyExplanationcreate table leona_verdict_cache (
box_id text primary key,
canonical_device_id text,
device_fingerprint text,
evidence_json jsonb not null,
created_at timestamptz not null default now(),
business_user_id text,
business_request_id text
);
建议你至少打这些指标:
/v1/verdict 成功率、P95 延迟日志建议:
boxId、requestId、status、error_type、latency_ms不要把某个 tag 直接等价成封禁。一个能跑起来、也能承受误报的最小模板是:
公开字段 authoritativeRiskTags 与 telemetryRiskTags 的分离,本质就是为了让你写出这种“可治理”的策略。
/v1/verdict 的签名算法与 single-use 语义写在公开 README/示例里。examples/boxid-verdict/ 提供多语言实现。公开仓库里的 examples/boxid-verdict/ 很有价值:它把签名算法、请求头、body 哈希等关键细节写成了可运行代码。但在真实业务里,你不会在主业务进程里直接 python query_boxid.py。你需要的是一个可复用、可观测、可回滚的“Leona 证据兑换组件”。
一个比较稳妥的做法是把它做成你后端内部的一个模块(或一个独立服务),并明确它的职责边界:
boxId + 业务上下文(userId / requestId / riskTier 等)/v1/verdict 暴露给客户端下面给一个“够用但不复杂”的 Node.js(TypeScript 风格)示例,展示如何把脚本升级成服务。
leonaClient:只负责签名与调用 /v1/verdictverdictStore:只负责落库与幂等decisionEngine:只负责把 evidence report + businessCtx → decisionapi:只负责暴露内部接口给业务系统调用(绝不对外)import express from "express";
import { queryVerdict } from "./leonaClient";
import { saveVerdictOnce, getCachedVerdictByBoxId } from "./verdictStore";
import { decide } from "./decisionEngine";
const app = express();
app.use(express.json());
// 内部接口:业务后端调用(不要暴露公网)
app.post("/internal/leona/exchange", async (req, res) => {
const { boxId, userId, requestId, isHighValue } = req.body;
if (!boxId || !userId || !requestId) return res.status(400).json({ error: "missing fields" });
// 1) 幂等:同一个 boxId 被重复请求时,先尝试从缓存/数据库读取
const cached = await getCachedVerdictByBoxId(boxId);
if (cached) {
const decision = decide(cached.report, { userId, requestId, isHighValue });
return res.json({ source: "cache", decision, report: cached.reportSummary });
}
// 2) 兑换:调用 /v1/verdict 换取证据报告(single-use,务必尽快落库)
const report = await queryVerdict(boxId);
// 3) 落库:用 boxId 做唯一键,保证并发下不会写入两份
await saveVerdictOnce({ boxId, userId, requestId, report });
// 4) 决策:示例决策;真实业务一般要引入风控等级与灰度
const decision = decide(report, { userId, requestId, isHighValue });
return res.json({ source: "live", decision, report: { boxId, canonicalDeviceId: report.canonicalDeviceId } });
});
app.listen(3000);
这个路由做了几件关键的“工程正确事情”:
isHighValue 等业务上下文纳入leonaClient:严格复用公开签名算法签名算法千万不要“凭感觉重写”,最好的方式是:
examples/boxid-verdict/nodejs/query_boxid.mjs 为基准提取成函数import crypto from "node:crypto";
const DEFAULT_ENDPOINT = "d82K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6D9k6h3!0F1j5g2)9J5k6i4S2A6P5h3q4F1M7$3S2S2L8W2)9J5k6h3y4G2L8g2)9J5c8Y4j5I4i4K6u0r3N6X3g2J5k6r3W2U0N6l9`.`.";
export async function queryVerdict(boxId: string) {
const secretKey = process.env.LEONA_SECRET_KEY!;
const endpoint = process.env.LEONA_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify({ boxId });
const timestamp = Date.now().toString();
const nonce = crypto.randomBytes(16).toString("base64url");
const bodySha256 = crypto.createHash("sha256").update(body).digest("hex");
const signingText = `${timestamp}\n${nonce}\n${bodySha256}`;
const signature = crypto.createHmac("sha256", secretKey).update(signingText).digest("base64url");
const resp = await fetch(endpoint, {
method: "POST",
headers: {
"Authorization": `Bearer ${secretKey}`,
"Content-Type": "application/json",
"X-Leona-Timestamp": timestamp,
"X-Leona-Nonce": nonce,
"X-Leona-Signature": signature,
},
body,
});
const text = await resp.text();
if (!resp.ok) throw new Error(`verdict failed: ${resp.status} ${text}`);
return JSON.parse(text);
}
注意:上面示例为了讲清楚直接写了 Authorization: Bearer <secretKey>(公开契约如此)。生产系统里你至少要:
一旦你把 /v1/verdict 接到生产流量里,马上会遇到并发问题:
在 single-use 语义下,最危险的情况是:两个并发请求同时去兑换同一个 BoxId,其中一个成功、另一个失败,然后你在业务里把失败当成“系统不稳定”。
解决方案是把幂等写进后端:
boxId 作为唯一键落库create table leona_verdict_cache (
box_id text primary key,
report_json jsonb not null,
created_at timestamptz not null default now()
);
写入时使用 insert ... on conflict do nothing,然后再读回:
这类“幂等 + 唯一键”是生产系统里处理 single-use token 的常规套路。
把错误分类做清楚,会直接提升你集成上线后的排障速度。
建议至少分这几类(你可以按业务需要扩展):
auth_failed:SecretKey 错误/权限错误signature_mismatch:签名不匹配(实现错误、body 不一致、base64url/padding 差异)timestamp_skew:时间窗错误(设备/环境时钟偏移、重放保护)nonce_replay:nonce 重放network_timeout:网络超时upstream_5xx:上游 5xxboxid_consumed:BoxId 已被消费(通常是并发/重试导致)重要的是:你的业务决策系统需要知道“失败是否可重试”。
network_timeout 可能可重试(但要避免重试导致二次消费)signature_mismatch 不可重试,必须修代码timestamp_skew 可能可恢复(校时/重建 session),但要可诊断如果你想把这条链路长期维护下去,至少需要三层测试:
用本文的固定测试向量锁死签名实现,避免某次重构把 JSON 序列化或 base64url 细节改坏。
把请求头、body 结构、必填字段做成测试:
把历史 evidence report 落库之后,你就能做策略回放:
这一步很关键:它把“安全”从一次性集成变成持续工程。
如果你在团队里推动这个落地,我建议你把验收物写成“可以看、可以跑、可以回归”的东西:
/internal/leona/exchange(或等价)leona_verdict_cache(或等价)做到这些,你的“BoxId → 证据报告 → 策略动作”链路才算真正进入生产形态。
下面这些坑,你只要踩过一次就会记一辈子。把它们写进团队的 code review checklist,会比事后排障省很多时间。
unix-time-ms。+// 要替换为 -/_。=,有的保留。公开示例使用“不带 padding”。timestamp + "\n" + nonce + "\n" + sha256(body),换行符与顺序必须一致。把这些坑写成 checklist 后,工程质量会有肉眼可见的提升。
separators=(",", ":")你会注意到公开 Python 示例里有一句:
json.dumps({"boxId": box_id}, separators=(",", ":"))
这不是“代码洁癖”,而是为了保证 body 的字节序列稳定。
签名里要对 requestBody 做 sha256。如果你的 JSON 序列化会插入多余空格:
{"boxId": "xxx"}{"boxId":"xxx"}它们语义相同,但字节不同,sha256 不同,签名自然不同。
因此,在任何语言里,你都应该把“最终发送的 body 字节序列”固定下来:
JSON.stringify 默认没有空格,一般稳定。最保险的方式仍然是:用固定测试向量做单测,把“body 的精确字符串”也锁死。
BoxId 的使用不应该是“临时加个字段”。你需要把它当成一种业务级证据关联键。
leonaBoxId(body 字段)或 X-Leona-BoxId(header)。boxId,并把它写入 request context(便于链路追踪)。requestId(业务已有就复用)。requestId、userId、boxId、decision、error_type、latency_ms。这样你的证据链才真正可审计:你能回答“这个用户为什么被挑战/拒绝”。
single-use 语义让重试变得微妙:你既想提高可用性,又不能因为重试导致二次消费与混乱。
建议把重试分成两层:
/v1/verdict 的重试(谨慎)只对“明显未到达上游”的错误做一次短重试,例如:
一旦你已经拿到明确的 HTTP 响应(尤其是非 2xx),不要盲目重试。
业务侧如果要重试同一个 boxId:
如果你把“查缓存优先”做对了,大部分重试都不会触发二次消费。
前面给了最小落库字段,但真实系统上线后,你往往会想要更多字段来支持运营与回归:
decision:当时的动作(allow/challenge/deny)policy_version:策略版本号(用于回放)risk_tier:业务风险等级(高价值/低价值)app_version / sdk_version:定位某次版本引入的误报country/region、network_type:解释性维度(注意合规)这些字段不需要一口气全上,但你要给 schema 留扩展空间。
很多团队担心“多一次后端调用会不会太慢、太贵”。实际工程里,只要你把缓存与落库做对,证据链往往是可控的:
/v1/verdict 只在关键业务动作发生时调用(登录/支付/发帖/领券),不是每个请求都调。更重要的是:你用落库换来的,是“可审计、可回放、可回归”的工程能力,这会显著降低误报治理的人力成本。
做设备/环境证据链时,务必把“最小必要”原则写进规范:
这部分不属于 Leona 专属,而是任何“设备证据平台”都会面对的工程现实。
如果你只把它当成一段调用代码,半年后你会发现没有人维护、没有人敢改、出了事也没人能解释。
更好的方式是把它当成“产品能力”交付:
做到这四点,你的 BoxId 证据链才算真正进入“可持续”的状态。
把 BoxId 当作“证据兑换券”,把 /v1/verdict 当作“后端证据报告接口”,再配上“幂等落库 + 错误分类 + 固定签名单测 + 分层策略”,你就拥有了一条能上线、能运维、能迭代的移动端证据链。
补充一句现实经验:先把签名实现与幂等落库做对,再谈策略与覆盖面;否则你会在“到底是系统不稳定还是我们实现错了”的争论里浪费大量时间。
当这条链路稳定后,你会发现移动端安全讨论会从“谁更会写检测”转向“证据是否成立、策略是否合理、误报如何治理”——这才是工程化的胜利。
single-use token 的正确姿势不是“祈祷不要并发”,而是把幂等写进存储层。这里给两种常见实现方式。
insert ... on conflict do nothingbox_id 做主键示例(Postgres):
create table leona_verdict_cache (
box_id text primary key,
report_json jsonb not null,
created_at timestamptz not null default now()
);
-- 写入
insert into leona_verdict_cache(box_id, report_json)
values ($1, $2)
on conflict (box_id) do nothing;
-- 读取
select report_json from leona_verdict_cache where box_id = $1;
对单个 boxId 做短 TTL 锁,避免并发打上游。
多数业务场景里,“方案 A + 缓存优先读”已经足够。
这部分建议写进你的 runbook:
更多【Android安全-安卓设备指纹的正确使用方式(Leona)】相关视频教程:www.yxfzedu.com