@adep/cli 0.4.4 → 0.4.6
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.
- package/README.md +107 -11
- package/dist/index.js +33802 -27354
- package/dist/vite.js +247 -17
- package/dist/worker-entry.js +163 -1
- package/package.json +7 -6
- package/templates/app-desktop/README.md +67 -2
- package/templates/app-desktop/identity-store.mjs +309 -0
- package/templates/app-desktop/identity.mjs +517 -0
- package/templates/app-desktop/main.cjs +17 -0
- package/templates/app-desktop/main.mjs +218 -2
- package/templates/app-desktop/offline-runtime.mjs +72 -3
- package/templates/app-desktop/package.json +10 -6
- package/templates/app-desktop/recovery-ui.mjs +300 -0
- package/templates/app-desktop/sync-loop.mjs +137 -0
- package/templates/app-mobile/README.md +47 -9
- package/templates/app-mobile/package.json +2 -1
- package/templates/app-mobile/plugins/adep-bridge/Package.swift +5 -1
- package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
- package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
- package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +3 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
- package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
- package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
- package/templates/app-mobile/template.json +12 -3
- package/templates/app-mobile/web/adep-bridge.js +103 -9
- package/templates/app-mobile/web/offline-runtime.js +230 -0
- package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
- package/templates/app-mobile-xtool/Package.swift +59 -0
- package/templates/app-mobile-xtool/README.md +81 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
- package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
- package/templates/app-mobile-xtool/adep.permissions.json +4 -0
- package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
- package/templates/app-mobile-xtool/template.json +15 -0
- package/templates/app-mobile-xtool/xtool.yml +35 -0
- package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt
CHANGED
|
@@ -4,6 +4,8 @@ import com.getcapacitor.Plugin
|
|
|
4
4
|
import com.getcapacitor.PluginCall
|
|
5
5
|
import com.getcapacitor.PluginMethod
|
|
6
6
|
import com.getcapacitor.annotation.CapacitorPlugin
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* AgentDeploy 移动壳原生桥(APP-009,Android 侧)。
|
|
@@ -19,6 +21,13 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|
|
19
21
|
class AdepBridge : Plugin() {
|
|
20
22
|
private var gate: AdepPermissionGate? = null
|
|
21
23
|
|
|
24
|
+
/** 设备本地 SQLite(APP-023)。懒建:首次 sqlite 调用时初始化(与 iOS 同)。 */
|
|
25
|
+
@Volatile
|
|
26
|
+
private var sqlite: AdepSqlite? = null
|
|
27
|
+
|
|
28
|
+
/** 安装身份自举(APP-027)用的后台线程句柄(持有引用只为可读性;勿在主线程 join)。 */
|
|
29
|
+
private var identityThread: Thread? = null
|
|
30
|
+
|
|
22
31
|
override fun load() {
|
|
23
32
|
gate = try {
|
|
24
33
|
AdepPermissionGate.load(context)
|
|
@@ -28,6 +37,69 @@ class AdepBridge : Plugin() {
|
|
|
28
37
|
android.util.Log.e("adep-bridge", "权限清单加载失败", error)
|
|
29
38
|
null
|
|
30
39
|
}
|
|
40
|
+
startIdentityBootstrap()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 安装身份自举(APP-027):首启向服务端签发端点换取 ownerKey,只存 Keystore。
|
|
45
|
+
*
|
|
46
|
+
* 三条边界(与 iOS `startIdentityBootstrap` 同口径):
|
|
47
|
+
* 1. **后台线程**——`load()` 在插件注册链上,同步网络会阻塞首屏;
|
|
48
|
+
* 主线程发网络请求在 Android 上是直接抛 `NetworkOnMainThreadException`。
|
|
49
|
+
* 2. **失败不阻断**——断网 / 5xx / 响应形态不符只记带原因码的日志,下次启动重试。
|
|
50
|
+
* 3. **幂等**——已有 ownerKey 不发请求(行为级门的分母断言:请求数 == 1)。
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ **不做在 JS 面**:`dist/plugin.js` 对象键集合不变(页面拿不到写凭据的通道)。
|
|
53
|
+
*/
|
|
54
|
+
private fun startIdentityBootstrap() {
|
|
55
|
+
val appContext = context.applicationContext
|
|
56
|
+
val bootstrap = AdepIdentityBootstrap(
|
|
57
|
+
configData = {
|
|
58
|
+
try {
|
|
59
|
+
appContext.assets.open("public/${AdepIdentityLogic.CONFIG_FILE_NAME}")
|
|
60
|
+
.bufferedReader().use { it.readText() }
|
|
61
|
+
} catch (error: Exception) {
|
|
62
|
+
null
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
storedOwnerKey = { AdepKeystore.loadOwnerKey(appContext) },
|
|
66
|
+
persistOwnerKey = { key ->
|
|
67
|
+
try {
|
|
68
|
+
AdepKeystore.saveOwnerKey(appContext, key)
|
|
69
|
+
true
|
|
70
|
+
} catch (error: Exception) {
|
|
71
|
+
android.util.Log.e("adep-bridge", "ownerKey 写入 Keystore 失败", error)
|
|
72
|
+
false
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
// APP-028:恢复码与 ownerKey 一起落 Keystore(服务端只存摘要,明文只此一次)。
|
|
76
|
+
// 读口的唯一用处是**写后读回校验**——写成功读不出等于码已丢。
|
|
77
|
+
storedRecoveryCode = { AdepKeystore.loadRecoveryCode(appContext) },
|
|
78
|
+
persistRecoveryCode = { code ->
|
|
79
|
+
try {
|
|
80
|
+
AdepKeystore.saveRecoveryCode(appContext, code)
|
|
81
|
+
true
|
|
82
|
+
} catch (error: Exception) {
|
|
83
|
+
android.util.Log.e("adep-bridge", "恢复码写入 Keystore 失败", error)
|
|
84
|
+
false
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
transport = AdepHttpTransport(),
|
|
88
|
+
platform = "android",
|
|
89
|
+
log = { message -> android.util.Log.i("adep-identity", message) },
|
|
90
|
+
// 库已有数据且此前用 FALLBACK_KEY 建的 ⇒ 这里换成正式 key(rekey 在 AdepSqlite 内收口)。
|
|
91
|
+
onIssued = { key -> sqlite?.adoptKey(key) },
|
|
92
|
+
)
|
|
93
|
+
identityThread = Thread({
|
|
94
|
+
val outcome = bootstrap.start()
|
|
95
|
+
if (outcome is AdepIdentityBootstrap.Outcome.Failed) {
|
|
96
|
+
// 失败已由 bootstrap 记过带原因码的日志;这里只留一条汇总便于搜索。
|
|
97
|
+
android.util.Log.w("adep-bridge", "ownerKey 首启签发失败:${outcome.code}")
|
|
98
|
+
}
|
|
99
|
+
}, "adep-identity").apply {
|
|
100
|
+
isDaemon = true
|
|
101
|
+
start()
|
|
102
|
+
}
|
|
31
103
|
}
|
|
32
104
|
|
|
33
105
|
/** 调用原生能力。门控先于一切实现:未声明 → 拒绝 + 审计;已声明未实现 → NOT_IMPLEMENTED。 */
|
|
@@ -60,27 +132,141 @@ class AdepBridge : Plugin() {
|
|
|
60
132
|
}
|
|
61
133
|
}
|
|
62
134
|
|
|
63
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* 读 ownerKey(Keystore)。首启尚未签发(或签发失败)时返回 null。
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ **`setOwnerKey` 已删除**(APP-027):它曾是「页面调不到、原生也没人调」的死方法——
|
|
139
|
+
* 写凭据的入口现在只有 [AdepIdentityBootstrap](首启自举)与 [AdepKeystore.saveOwnerKey]
|
|
140
|
+
* (恢复码换键流程的前置),两者都在原生侧,页面永远拿不到写凭据的通道。
|
|
141
|
+
*/
|
|
64
142
|
@PluginMethod
|
|
65
143
|
fun getOwnerKey(call: PluginCall) {
|
|
66
144
|
call.resolve(jsObject("value" to AdepKeystore.loadOwnerKey(context)))
|
|
67
145
|
}
|
|
68
146
|
|
|
69
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* 读恢复码(APP-034,独立 prefs 文件 `adep_keystore_recovery`)。无则 null。
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ **只读**:写口在 [AdepIdentityBootstrap](首启自举),页面没有写凭据的通道。
|
|
151
|
+
* 页面拿它做呈现 / 导出(认领凭据——服务端只存摘要、无法重发);与 iOS 侧
|
|
152
|
+
* `getRecoveryCode` 同形(app-android-bridge-contract 的三向对齐门咬着)。
|
|
153
|
+
*/
|
|
70
154
|
@PluginMethod
|
|
71
|
-
fun
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
155
|
+
fun getRecoveryCode(call: PluginCall) {
|
|
156
|
+
call.resolve(jsObject("value" to AdepKeystore.loadRecoveryCode(context)))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: - 设备本地 SQLite(APP-023,与 iOS AdepBridge.swift withSqlite 同形)
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* sqlite 三方法的公共骨架:门控 → sqlite 懒建(长持复用)→ params 三态分流 → 执行 → 信封。
|
|
163
|
+
* 形状对齐 `DeviceSqliteBridge`(bridge.ts):sql + params(可选数组);
|
|
164
|
+
* run → { changes },all → { rows },get → { row | null }。
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ **sqlite 实例必须懒建一次、跨调用复用**(2026-10-22 iOS 真机第六轮教训):
|
|
167
|
+
* `runOwnedWrite` 事务协议(SAVEPOINT → 业务 → RELEASE/ROLLBACK)是**三次独立桥调用**,
|
|
168
|
+
* SAVEPOINT 是连接级状态——每次调用重建实例 = 连接换 = 「no such savepoint」。这里字段
|
|
169
|
+
* `sqlite` 首调建、其后复用(配合执行层 {@link AdepSqlite} 的内部 helper 长持连接)。
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ **每条路径必须恰好 resolve 一次**(2026-10-22 iOS 真机第五轮血的教训:params
|
|
172
|
+
* 缺席被误判为非法 ⇒ CAPPluginCall 悬空、JS promise 永不落定、页面装配**静默卡死**)。
|
|
173
|
+
* 三态分流在纯逻辑层 {@link sqliteBindParams};失败信封带 ok/code/message,成功信封
|
|
174
|
+
* 经唯一组装点 {@link sqliteSuccessEnvelope}。
|
|
175
|
+
*/
|
|
176
|
+
private fun withSqlite(call: PluginCall, capability: String, body: (AdepSqlite, String, List<Any>) -> JSONObject) {
|
|
177
|
+
val gate = gate
|
|
178
|
+
if (gate == null) {
|
|
179
|
+
call.resolve(jsObject("ok" to false, "code" to "GATE_UNAVAILABLE", "message" to "权限门控未建立(清单缺失)"))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!gate.check(capability)) {
|
|
183
|
+
call.resolve(
|
|
184
|
+
jsObject(
|
|
185
|
+
"ok" to false,
|
|
186
|
+
"code" to AdepPermissionGate.PERMISSION_DENIED,
|
|
187
|
+
"message" to "原生能力 \"$capability\" 未在应用 manifest 的 permissions 中声明(未声明即不可用,且不会在运行时申请)",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
val sql = call.getString("sql")
|
|
193
|
+
if (sql.isNullOrEmpty()) {
|
|
194
|
+
call.resolve(jsObject("ok" to false, "code" to SqliteCode.INVALID_PARAM, "message" to "sql 必须为非空字符串"))
|
|
75
195
|
return
|
|
76
196
|
}
|
|
77
|
-
|
|
78
|
-
|
|
197
|
+
// isNullOrEmpty 后 sql 智能转型为非空,闭包捕获的 sql 即非空。
|
|
198
|
+
// 懒建一次、跨调用复用(长持连接是 SAVEPOINT 跨调用存活的前提)。首调失败 → fail loud。
|
|
199
|
+
val instance = sqlite ?: runCatching { AdepSqlite.open(context) }
|
|
200
|
+
.getOrNull()
|
|
201
|
+
?: run {
|
|
202
|
+
call.resolve(jsObject("ok" to false, "code" to SqliteCode.OPEN_FAILED, "message" to "SQLite 打开失败"))
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
this.sqlite = instance
|
|
206
|
+
// params 三态分流(纯逻辑层):缺席 → [](合法无参);元素非法 → 抛(回 INVALID_PARAM)。
|
|
207
|
+
val bindParams = try {
|
|
208
|
+
sqliteBindParams(call.getArray("params"))
|
|
209
|
+
} catch (error: SqliteBridgeError) {
|
|
210
|
+
call.resolve(jsObject("ok" to false, "code" to error.code, "message" to error.message))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
val data = body(instance, sql, bindParams)
|
|
215
|
+
// sqliteSuccessEnvelope 返回 JSONObject(纯逻辑层契约),resolve 要 JSObject
|
|
216
|
+
// ⇒ 经 jsObject 重建(字段平移,键集不变——ok/changes/rows/row + 业务键)。
|
|
217
|
+
val envelope = sqliteSuccessEnvelope(data)
|
|
218
|
+
val payload = com.getcapacitor.JSObject()
|
|
219
|
+
val keys = envelope.names() ?: org.json.JSONArray()
|
|
220
|
+
for (i in 0 until keys.length()) {
|
|
221
|
+
val k = keys.getString(i)
|
|
222
|
+
payload.put(k, envelope.get(k))
|
|
223
|
+
}
|
|
224
|
+
call.resolve(payload)
|
|
225
|
+
} catch (error: SqliteBridgeError) {
|
|
226
|
+
call.resolve(jsObject("ok" to false, "code" to error.code, "message" to error.message))
|
|
227
|
+
} catch (error: Exception) {
|
|
228
|
+
call.resolve(jsObject("ok" to false, "code" to SqliteCode.EXEC_FAILED, "message" to error.message))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 执行写语句(DDL / DML)。JS 面:`AdepBridge.sqliteRun({ sql, params? })` → `{ changes }`。 */
|
|
233
|
+
@PluginMethod
|
|
234
|
+
fun sqliteRun(call: PluginCall) {
|
|
235
|
+
withSqlite(call, "sqlite.run") { sqlite, sql, params ->
|
|
236
|
+
jsObject("changes" to sqlite.run(sql, params))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 执行查询返回多行。JS 面:`AdepBridge.sqliteAll({ sql, params? })` → `{ rows }`。 */
|
|
241
|
+
@PluginMethod
|
|
242
|
+
fun sqliteAll(call: PluginCall) {
|
|
243
|
+
withSqlite(call, "sqlite.all") { sqlite, sql, params ->
|
|
244
|
+
jsObject("rows" to sqlite.all(sql, params))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 执行单行查询。JS 面:`AdepBridge.sqliteGet({ sql, params? })` → `{ row | null }`。 */
|
|
249
|
+
@PluginMethod
|
|
250
|
+
fun sqliteGet(call: PluginCall) {
|
|
251
|
+
withSqlite(call, "sqlite.get") { sqlite, sql, params ->
|
|
252
|
+
jsObject("row" to sqlite.get(sql, params))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 插件销毁时关闭长持连接(幂等;等价 iOS `close()`,防 WebView reload 场景 fd 泄漏)。 */
|
|
257
|
+
override fun handleOnDestroy() {
|
|
258
|
+
sqlite?.close()
|
|
259
|
+
sqlite = null
|
|
260
|
+
super.handleOnDestroy()
|
|
79
261
|
}
|
|
80
262
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
263
|
+
/** 组 JS 回包对象。⚠️ 必须是 `JSObject`(Capacitor 7 的 `PluginCall.resolve` 只收它),
|
|
264
|
+
* 裸 `JSONObject` 编译不过(APP-026 行为级门把执行层拉进 kotlinc 编译面时暴露)。
|
|
265
|
+
* ⚠️ `object` 是 Kotlin 关键字,不能当变量名(此前写 `val object =` 是靠「从未真编译」
|
|
266
|
+
* 存活的死代码形态)——变量名改 `envelope`。 */
|
|
267
|
+
private fun jsObject(vararg pairs: Pair<String, Any?>): com.getcapacitor.JSObject {
|
|
268
|
+
val envelope = com.getcapacitor.JSObject()
|
|
269
|
+
for ((key, value) in pairs) envelope.put(key, value)
|
|
270
|
+
return envelope
|
|
85
271
|
}
|
|
86
272
|
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
package com.adep.bridge
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import java.net.HttpURLConnection
|
|
5
|
+
import java.net.URL
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 安装身份自举(Android,APP-027;PRD §2.11.5「设备身份与恢复」/ ADR-0008 第 2 条)。
|
|
9
|
+
*
|
|
10
|
+
* 与 iOS `AdepIdentity.swift` **逐条同语义**(§3.2.8 硬约束④:两平台桥同形):
|
|
11
|
+
*
|
|
12
|
+
* 1. **ownerKey 只能由服务端签发**(本地自造 / 构建期预置判违规)。服务端
|
|
13
|
+
* `POST /api/v1/projects/:projectId/app-installations` 早在 APP-007 交付,但客户端
|
|
14
|
+
* 从来没有调用方 ⇒ `getOwnerKey` 恒 null、单主同步(`Bearer <ownerKey>`)真机不可用、
|
|
15
|
+
* SQLCipher 库 key 恒落硬编码缺省(key 就在二进制里)。
|
|
16
|
+
* 2. **不做在 JS 面**:`dist/plugin.js` 对象键集合不变——页面没有写凭据的通道
|
|
17
|
+
* (XSS 不能替换 ownerKey 劫持数据分区)。
|
|
18
|
+
* 3. **失败不改变可用性**:断网 / 5xx / 响应形态不符 ⇒ 不写空值、不覆盖既有值,
|
|
19
|
+
* 只留带原因码的日志,下次启动重试;首启签发失败不阻止 app 启动。
|
|
20
|
+
* 4. **幂等**:已有合法 ownerKey ⇒ 不发请求(行为级门的分母断言:请求数 == 1)。
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **本文件只依赖 JVM + org.json**(不 import 任何 `android.*`):这样 `kotlinc` 编完
|
|
23
|
+
* 能用 `java` 直接真跑(`adep-identity.test.ts` 的 Android 段),判定逻辑不必靠读源码猜。
|
|
24
|
+
* 需要 `Context` 的部分(读 `assets/public/adep.app.json`)留在 `AdepBridge`。
|
|
25
|
+
*/
|
|
26
|
+
data class AdepIdentityConfig(val platformUrl: String, val projectSlug: String)
|
|
27
|
+
|
|
28
|
+
/** 签发响应里客户端真正需要的三样(`partition` 是 `sha256(ownerKey)`,客户端不用)。 */
|
|
29
|
+
data class AdepIdentityIssue(val ownerKey: String, val installationId: String, val recoveryCode: String)
|
|
30
|
+
|
|
31
|
+
/** 自举失败(原因码 + 可读详情)。`code` 会原样进日志,排障先看码。 */
|
|
32
|
+
class AdepIdentityFailure(val code: String, message: String) : Exception(message)
|
|
33
|
+
|
|
34
|
+
/** 自举失败 / 跳过的原因码(与 iOS `AdepIdentityCode` 逐字同值)。 */
|
|
35
|
+
object AdepIdentityCode {
|
|
36
|
+
const val CONFIG_MISSING = "IDENTITY_CONFIG_MISSING"
|
|
37
|
+
const val CONFIG_INVALID = "IDENTITY_CONFIG_INVALID"
|
|
38
|
+
const val REQUEST_FAILED = "IDENTITY_REQUEST_FAILED"
|
|
39
|
+
const val RESPONSE_INVALID = "IDENTITY_RESPONSE_INVALID"
|
|
40
|
+
const val STORE_FAILED = "IDENTITY_STORE_FAILED"
|
|
41
|
+
const val ALREADY_ISSUED = "IDENTITY_ALREADY_ISSUED"
|
|
42
|
+
const val ALREADY_STARTED = "IDENTITY_ALREADY_STARTED"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 判定层(纯函数)。常量与服务端 `identity/contract.ts` / `sync-routes.ts` **逐字对应**
|
|
47
|
+
* (`OWNER_KEY_PREFIX` / `OWNER_KEY_BYTES=32` ⇒ 64 位 hex / 端点路径);漂移由
|
|
48
|
+
* `adep-identity.test.ts` 的跨端一致性门对账。
|
|
49
|
+
*/
|
|
50
|
+
object AdepIdentityLogic {
|
|
51
|
+
/** 壳配置文件名(与 iOS `AdepShellConfig.configFileName` 同值;Android 侧落点只有 assets/public)。 */
|
|
52
|
+
const val CONFIG_FILE_NAME = "adep.app.json"
|
|
53
|
+
const val OWNER_KEY_PREFIX = "adep_ok_"
|
|
54
|
+
const val OWNER_KEY_HEX_LENGTH = 64
|
|
55
|
+
|
|
56
|
+
/** 签发端点(平台侧挂在 `/api` 前缀下;`:projectId` 服务端接受 **id 或 slug**)。 */
|
|
57
|
+
const val ISSUE_PATH_PREFIX = "/api/v1/projects/"
|
|
58
|
+
const val ISSUE_PATH_SUFFIX = "/app-installations"
|
|
59
|
+
|
|
60
|
+
private val HEX = "0123456789abcdef".toSet()
|
|
61
|
+
|
|
62
|
+
/** ownerKey 形态:前缀 + 64 位**小写** hex(与服务端正则同形,刻意不宽松)。 */
|
|
63
|
+
fun isOwnerKey(value: String): Boolean {
|
|
64
|
+
if (!value.startsWith(OWNER_KEY_PREFIX)) return false
|
|
65
|
+
val hex = value.substring(OWNER_KEY_PREFIX.length)
|
|
66
|
+
if (hex.length != OWNER_KEY_HEX_LENGTH) return false
|
|
67
|
+
return hex.all { it in HEX }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** projectSlug 形态(与 `adep.app.json` schema 的 `^[a-z][a-z0-9-]{0,62}$` 同形)。 */
|
|
71
|
+
fun isProjectSlug(value: String): Boolean {
|
|
72
|
+
if (value.isEmpty() || value.length > 63) return false
|
|
73
|
+
if (value[0] !in 'a'..'z') return false
|
|
74
|
+
return value.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 解析 `adep.app.json`(只取签发需要的两个字段;完整 schema 校验在 CLI 侧)。 */
|
|
78
|
+
fun parseConfig(raw: String): AdepIdentityConfig {
|
|
79
|
+
val object0 = try {
|
|
80
|
+
JSONObject(raw)
|
|
81
|
+
} catch (error: Exception) {
|
|
82
|
+
throw AdepIdentityFailure(AdepIdentityCode.CONFIG_INVALID, "adep.app.json 不是 JSON 对象")
|
|
83
|
+
}
|
|
84
|
+
val platformUrl = object0.optString("platformUrl").takeIf { it.isNotEmpty() }
|
|
85
|
+
?: throw AdepIdentityFailure(AdepIdentityCode.CONFIG_INVALID, "adep.app.json 缺 platformUrl")
|
|
86
|
+
val scheme = try {
|
|
87
|
+
val parsed = URL(platformUrl)
|
|
88
|
+
val protocol = parsed.protocol.lowercase()
|
|
89
|
+
if (protocol != "http" && protocol != "https") null else parsed.host
|
|
90
|
+
} catch (error: Exception) {
|
|
91
|
+
null
|
|
92
|
+
}
|
|
93
|
+
if (scheme.isNullOrEmpty()) {
|
|
94
|
+
throw AdepIdentityFailure(
|
|
95
|
+
AdepIdentityCode.CONFIG_INVALID,
|
|
96
|
+
"platformUrl 不是合法的 http(s) URL:$platformUrl",
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
val slug = object0.optString("projectSlug").takeIf { it.isNotEmpty() }
|
|
100
|
+
?: throw AdepIdentityFailure(AdepIdentityCode.CONFIG_INVALID, "adep.app.json 缺 projectSlug")
|
|
101
|
+
if (!isProjectSlug(slug)) {
|
|
102
|
+
throw AdepIdentityFailure(
|
|
103
|
+
AdepIdentityCode.CONFIG_INVALID,
|
|
104
|
+
"projectSlug 形态不符(需小写字母开头,仅小写字母/数字/连字符):$slug",
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return AdepIdentityConfig(platformUrl, slug)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 组装签发端点。`platformUrl` 常带尾斜杠(`https://adep.top/`)⇒ 先归一,
|
|
112
|
+
* 否则拼出 `//api/v1/...`(服务端 404,症状像「端点没部署」)。
|
|
113
|
+
*/
|
|
114
|
+
fun issueUrl(config: AdepIdentityConfig): String =
|
|
115
|
+
config.platformUrl.trimEnd('/') + ISSUE_PATH_PREFIX + config.projectSlug + ISSUE_PATH_SUFFIX
|
|
116
|
+
|
|
117
|
+
/** 请求体:服务端 body 契约是 `{ platform }`(`t.Object({ platform: t.String() })`)。 */
|
|
118
|
+
fun issueBody(platform: String): String {
|
|
119
|
+
val escaped = platform.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
120
|
+
return "{\"platform\":\"$escaped\"}"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 解析签发响应:`{ installation: { installationId, ownerKey, recoveryCode, … } }`。
|
|
125
|
+
*
|
|
126
|
+
* **形态不符一律抛**(不写「尽量取到的那部分」):ownerKey 是 bearer 凭据,把空串 /
|
|
127
|
+
* 半截值写进 Keystore 等于把设备身份变成「永远 401」的垃圾状态,而且看起来像已签发
|
|
128
|
+
* (下次启动不再重试)——比直接失败难查得多。
|
|
129
|
+
*/
|
|
130
|
+
fun parseIssue(raw: String): AdepIdentityIssue {
|
|
131
|
+
val object0 = try {
|
|
132
|
+
JSONObject(raw)
|
|
133
|
+
} catch (error: Exception) {
|
|
134
|
+
throw AdepIdentityFailure(AdepIdentityCode.RESPONSE_INVALID, "签发响应不是 JSON 对象")
|
|
135
|
+
}
|
|
136
|
+
val installation = object0.optJSONObject("installation")
|
|
137
|
+
?: throw AdepIdentityFailure(AdepIdentityCode.RESPONSE_INVALID, "签发响应缺 installation 对象")
|
|
138
|
+
val ownerKey = installation.optString("ownerKey")
|
|
139
|
+
if (!isOwnerKey(ownerKey)) {
|
|
140
|
+
throw AdepIdentityFailure(
|
|
141
|
+
AdepIdentityCode.RESPONSE_INVALID,
|
|
142
|
+
"installation.ownerKey 形态不符(期望 ${OWNER_KEY_PREFIX} + $OWNER_KEY_HEX_LENGTH 位小写 hex)",
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
val installationId = installation.optString("installationId")
|
|
146
|
+
if (installationId.isEmpty()) {
|
|
147
|
+
throw AdepIdentityFailure(AdepIdentityCode.RESPONSE_INVALID, "installation.installationId 缺失或为空")
|
|
148
|
+
}
|
|
149
|
+
val recoveryCode = installation.optString("recoveryCode")
|
|
150
|
+
if (recoveryCode.isEmpty()) {
|
|
151
|
+
throw AdepIdentityFailure(AdepIdentityCode.RESPONSE_INVALID, "installation.recoveryCode 缺失或为空")
|
|
152
|
+
}
|
|
153
|
+
return AdepIdentityIssue(ownerKey, installationId, recoveryCode)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 日志脱敏:只留前缀与 6 位(凭据本身绝不进日志)。 */
|
|
157
|
+
fun maskOwnerKey(value: String): String {
|
|
158
|
+
val keep = OWNER_KEY_PREFIX.length + 6
|
|
159
|
+
return if (value.length <= keep) value else value.take(keep) + "…"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 恢复码脱敏(日志用,APP-028)。形态 `<installationId>.<secret>`:**installationId 不是机密**
|
|
164
|
+
* (它就是安装行主键,服务端接口里也以明文出现),secret 段才是凭据。
|
|
165
|
+
* ⇒ 保留 installationId + secret 前 4 位 + 总长度(长度能看出「码被截断」这类畸形)。
|
|
166
|
+
*/
|
|
167
|
+
fun maskRecoveryCode(value: String, installationId: String): String {
|
|
168
|
+
val head = if (installationId.isEmpty()) "" else "$installationId."
|
|
169
|
+
val secret = if (head.isNotEmpty() && value.startsWith(head)) value.removePrefix(head) else value
|
|
170
|
+
return "$head${secret.take(4)}…(len ${value.length})"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 传输层结果。`status == 0` = 没拿到 HTTP 响应(断网 / DNS / 超时 / TLS),`error` 带原因。 */
|
|
175
|
+
data class AdepIdentityHttpResult(val status: Int, val body: String?, val error: String?)
|
|
176
|
+
|
|
177
|
+
/** 传输协议(可注入 ⇒ 行为级门用假传输驱动全部失败分支,不打真网络)。 */
|
|
178
|
+
fun interface AdepIdentityTransport {
|
|
179
|
+
fun post(url: String, body: String, timeoutMillis: Int): AdepIdentityHttpResult
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 生产传输:`HttpURLConnection`(不引第三方依赖)。阻塞式——调用方必须在后台线程。 */
|
|
183
|
+
class AdepHttpTransport : AdepIdentityTransport {
|
|
184
|
+
override fun post(url: String, body: String, timeoutMillis: Int): AdepIdentityHttpResult {
|
|
185
|
+
var connection: HttpURLConnection? = null
|
|
186
|
+
return try {
|
|
187
|
+
connection = URL(url).openConnection() as HttpURLConnection
|
|
188
|
+
connection.requestMethod = "POST"
|
|
189
|
+
connection.connectTimeout = timeoutMillis
|
|
190
|
+
connection.readTimeout = timeoutMillis
|
|
191
|
+
connection.doOutput = true
|
|
192
|
+
connection.setRequestProperty("Content-Type", "application/json")
|
|
193
|
+
connection.setRequestProperty("Accept", "application/json")
|
|
194
|
+
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
195
|
+
val status = connection.responseCode
|
|
196
|
+
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
|
197
|
+
val text = stream?.bufferedReader()?.use { it.readText() }
|
|
198
|
+
AdepIdentityHttpResult(status, text, null)
|
|
199
|
+
} catch (error: Exception) {
|
|
200
|
+
AdepIdentityHttpResult(0, null, error.message ?: error.javaClass.simpleName)
|
|
201
|
+
} finally {
|
|
202
|
+
connection?.disconnect()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 首启自举:查本地 →(缺则)签发 → 写 Keystore → 通知库换 key。
|
|
209
|
+
*
|
|
210
|
+
* 副作用全部经构造参数注入(存储 / 配置 / 传输 / 日志 / 换 key)⇒ 三条判定
|
|
211
|
+
* (要不要请求、写不写、写什么)都能被行为级门真跑。
|
|
212
|
+
*
|
|
213
|
+
* ⚠️ `start()` **同步阻塞**;插件在后台线程上调用它(主线程网络 = NetworkOnMainThreadException)。
|
|
214
|
+
*/
|
|
215
|
+
class AdepIdentityBootstrap(
|
|
216
|
+
private val configData: () -> String?,
|
|
217
|
+
private val storedOwnerKey: () -> String?,
|
|
218
|
+
private val persistOwnerKey: (String) -> Boolean,
|
|
219
|
+
/** 读本地既有恢复码(写后读回校验用;APP-028)。 */
|
|
220
|
+
private val storedRecoveryCode: () -> String?,
|
|
221
|
+
/** 写恢复码;**与 ownerKey 同生命周期**(每次成功签发覆盖两把)。 */
|
|
222
|
+
private val persistRecoveryCode: (String) -> Boolean,
|
|
223
|
+
private val transport: AdepIdentityTransport,
|
|
224
|
+
private val platform: String,
|
|
225
|
+
private val log: (String) -> Unit,
|
|
226
|
+
private val onIssued: (String) -> Unit,
|
|
227
|
+
private val timeoutMillis: Int = 10_000,
|
|
228
|
+
) {
|
|
229
|
+
/** 自举结果(与 iOS `AdepIdentityBootstrap.Outcome` 同形)。 */
|
|
230
|
+
sealed class Outcome {
|
|
231
|
+
/** 签发并落库成功。 */
|
|
232
|
+
class Issued(val ownerKey: String) : Outcome()
|
|
233
|
+
|
|
234
|
+
/** 未发请求:已签发 / 重复启动。 */
|
|
235
|
+
class Skipped(val code: String) : Outcome()
|
|
236
|
+
|
|
237
|
+
/** 失败(原因码 + 详情)。**没有任何本地写入发生**。 */
|
|
238
|
+
class Failed(val code: String, val detail: String) : Outcome()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private val lock = Any()
|
|
242
|
+
private var started = false
|
|
243
|
+
|
|
244
|
+
@Suppress("ReturnCount")
|
|
245
|
+
fun start(): Outcome {
|
|
246
|
+
synchronized(lock) {
|
|
247
|
+
if (started) {
|
|
248
|
+
log("[adep-identity] ${AdepIdentityCode.ALREADY_STARTED}: 已启动过,忽略重复调用")
|
|
249
|
+
return Outcome.Skipped(AdepIdentityCode.ALREADY_STARTED)
|
|
250
|
+
}
|
|
251
|
+
started = true
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ① 已有合法 ownerKey ⇒ 一枚安装只签发一次(这条路径下请求数必须为 0)。
|
|
255
|
+
val existing = storedOwnerKey()
|
|
256
|
+
if (!existing.isNullOrEmpty()) {
|
|
257
|
+
if (AdepIdentityLogic.isOwnerKey(existing)) {
|
|
258
|
+
log(
|
|
259
|
+
"[adep-identity] ${AdepIdentityCode.ALREADY_ISSUED}: 本地已有 ownerKey" +
|
|
260
|
+
"(${AdepIdentityLogic.maskOwnerKey(existing)}),不重复签发",
|
|
261
|
+
)
|
|
262
|
+
return Outcome.Skipped(AdepIdentityCode.ALREADY_ISSUED)
|
|
263
|
+
}
|
|
264
|
+
// 形态不符(历史垃圾值 / 手写调试值):不是可用身份,重新签发覆盖它——
|
|
265
|
+
// 不静默当「已签发」,那会让 app 永远带着一枚服务端不认的凭据。
|
|
266
|
+
log("[adep-identity] 本地 ownerKey 形态不符(长度 ${existing.length}),将重新签发")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ② 配置(assets/public/adep.app.json)。缺失与非法分开报:打包问题 vs 配置写错。
|
|
270
|
+
val rawConfig = configData()
|
|
271
|
+
?: return fail(
|
|
272
|
+
AdepIdentityCode.CONFIG_MISSING,
|
|
273
|
+
"assets 缺少 public/${AdepIdentityLogic.CONFIG_FILE_NAME}(CLI build 写入、cap sync 同步)",
|
|
274
|
+
)
|
|
275
|
+
val config = try {
|
|
276
|
+
AdepIdentityLogic.parseConfig(rawConfig)
|
|
277
|
+
} catch (error: AdepIdentityFailure) {
|
|
278
|
+
return fail(error.code, error.message ?: "")
|
|
279
|
+
} catch (error: Exception) {
|
|
280
|
+
return fail(AdepIdentityCode.CONFIG_INVALID, error.message ?: error.javaClass.simpleName)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ③ 请求(status 0 = 没拿到响应;非 2xx = 服务端拒绝,两者都落 REQUEST_FAILED)。
|
|
284
|
+
val url = AdepIdentityLogic.issueUrl(config)
|
|
285
|
+
val result = transport.post(url, AdepIdentityLogic.issueBody(platform), timeoutMillis)
|
|
286
|
+
if (result.status !in 200..299) {
|
|
287
|
+
val detail = result.error?.let { "传输层失败:$it(url: $url)" }
|
|
288
|
+
?: "HTTP ${result.status}(url: $url;响应:${result.body?.take(200) ?: ""})"
|
|
289
|
+
return fail(AdepIdentityCode.REQUEST_FAILED, detail)
|
|
290
|
+
}
|
|
291
|
+
val rawBody = result.body
|
|
292
|
+
?: return fail(AdepIdentityCode.RESPONSE_INVALID, "HTTP ${result.status} 但响应体为空")
|
|
293
|
+
|
|
294
|
+
// ④ 解析 + 落库。写失败也算失败:没写进去 = 下次还会签发(多一枚孤儿安装行)。
|
|
295
|
+
val issue = try {
|
|
296
|
+
AdepIdentityLogic.parseIssue(rawBody)
|
|
297
|
+
} catch (error: AdepIdentityFailure) {
|
|
298
|
+
return fail(error.code, error.message ?: "")
|
|
299
|
+
} catch (error: Exception) {
|
|
300
|
+
return fail(AdepIdentityCode.RESPONSE_INVALID, error.message ?: error.javaClass.simpleName)
|
|
301
|
+
}
|
|
302
|
+
// ⑤ 恢复码**先**落库并读回校验(APP-028)。
|
|
303
|
+
//
|
|
304
|
+
// 顺序即不变量:**本地有 ownerKey ⇒ 本地必有与它同源的恢复码**。
|
|
305
|
+
// 反过来(先写 ownerKey)会出现「app 已带着这枚身份跑,用户却永远拿不到恢复码」——
|
|
306
|
+
// 恢复码明文只在这一次响应里存在(服务端只存 recoveryCodeHash,任何端点都不重发),
|
|
307
|
+
// 丢了就是该安装的分区永久不可认领。所以宁可不落 ownerKey、下次启动重签。
|
|
308
|
+
if (!persistRecoveryCode(issue.recoveryCode)) {
|
|
309
|
+
return fail(
|
|
310
|
+
AdepIdentityCode.STORE_FAILED,
|
|
311
|
+
"恢复码写入 Keystore 失败(installationId: ${issue.installationId})——本次不落 ownerKey,下次启动重新签发",
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
// 读回校验:写报成功但读不出是真实故障形态(Keystore 密钥失效 / prefs 被清)。
|
|
315
|
+
// 只看写返回码会把「码已丢」当成功——正是 APP-028 要消灭的静默。
|
|
316
|
+
if (storedRecoveryCode() != issue.recoveryCode) {
|
|
317
|
+
return fail(
|
|
318
|
+
AdepIdentityCode.STORE_FAILED,
|
|
319
|
+
"恢复码写入后读回不一致(installationId: ${issue.installationId})——本次不落 ownerKey",
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
// ⑥ ownerKey 落库(写失败也算失败:没写进去 = 下次还会签发,多一枚孤儿安装行)。
|
|
323
|
+
if (!persistOwnerKey(issue.ownerKey)) {
|
|
324
|
+
return fail(
|
|
325
|
+
AdepIdentityCode.STORE_FAILED,
|
|
326
|
+
"ownerKey 写入 Keystore 失败(installationId: ${issue.installationId})",
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
log(
|
|
330
|
+
"[adep-identity] 签发成功(installationId: ${issue.installationId}, platform: $platform, " +
|
|
331
|
+
"recoveryCode: ${AdepIdentityLogic.maskRecoveryCode(issue.recoveryCode, issue.installationId)})",
|
|
332
|
+
)
|
|
333
|
+
onIssued(issue.ownerKey)
|
|
334
|
+
return Outcome.Issued(issue.ownerKey)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 失败收口:日志带原因码(排障先看码),并原样返回。 */
|
|
338
|
+
private fun fail(code: String, detail: String): Outcome {
|
|
339
|
+
log("[adep-identity] $code: $detail")
|
|
340
|
+
return Outcome.Failed(code, detail)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -11,14 +11,25 @@ import javax.crypto.SecretKey
|
|
|
11
11
|
import javax.crypto.spec.GCMParameterSpec
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* ownerKey
|
|
14
|
+
* ownerKey / 恢复码的 Keystore 加密存储(Android,APP-009 / APP-027 / APP-028)。
|
|
15
15
|
*
|
|
16
16
|
* PRD §2.11.5「设备身份与恢复」:ownerKey 移动端**只存 Keychain / Keystore**——不落
|
|
17
17
|
* 明文 SharedPreferences / 文件(客户端产物等同公开,明文落盘等于把设备身份交给解包者)。
|
|
18
18
|
*
|
|
19
|
-
* 实现:Android Keystore 系统生成/持有 AES
|
|
19
|
+
* 实现:Android Keystore 系统生成/持有 AES 密钥(不可导出),凭据用 AES/GCM 加密后
|
|
20
20
|
* 存 SharedPreferences 密文。密钥本身留在 Keystore(硬件/系统隔离),应用进程只能用它
|
|
21
21
|
* 加解密、拿不到密钥材料。
|
|
22
|
+
*
|
|
23
|
+
* ## 两个槽位(prefs 文件区分,APP-028)
|
|
24
|
+
*
|
|
25
|
+
* - `adep_keystore`(`owner_key_*` 键):分区凭据。
|
|
26
|
+
* - `adep_keystore_recovery`(`recovery_code_*` 键):换机 / 重装认领同一分区用的恢复码。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ **刻意不复用同一个 prefs 文件**:[deleteOwnerKey] 会 `.clear()` 整个文件,恢复码若同文件
|
|
29
|
+
* 会在换装 / 认领时被顺手抹掉——而服务端只存摘要,抹掉即永久丢失。
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ **既有 ownerKey 的 prefs 文件名与键名不可改**:已装机设备上是既有状态,改名 = 读不到
|
|
32
|
+
* ownerKey ⇒ 库 key 失配 ⇒ 数据全丢(库是用旧 key 建的,重新签发救不回来)。
|
|
22
33
|
*/
|
|
23
34
|
object AdepKeystore {
|
|
24
35
|
private const val KEYSTORE = "AndroidKeyStore"
|
|
@@ -26,6 +37,9 @@ object AdepKeystore {
|
|
|
26
37
|
private const val PREFS_NAME = "adep_keystore"
|
|
27
38
|
private const val PREFS_CIPHERTEXT = "owner_key_ciphertext"
|
|
28
39
|
private const val PREFS_IV = "owner_key_iv"
|
|
40
|
+
private const val PREFS_RECOVERY_NAME = "adep_keystore_recovery"
|
|
41
|
+
private const val PREFS_RECOVERY_CIPHERTEXT = "recovery_code_ciphertext"
|
|
42
|
+
private const val PREFS_RECOVERY_IV = "recovery_code_iv"
|
|
29
43
|
private const val GCM_TAG_BITS = 128
|
|
30
44
|
|
|
31
45
|
/** 读 ownerKey;无则返回 null(首启未签发)。 */
|
|
@@ -63,6 +77,46 @@ object AdepKeystore {
|
|
|
63
77
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply()
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
// MARK: - 恢复码(APP-028,独立 prefs 文件)
|
|
81
|
+
|
|
82
|
+
/** 读恢复码;无则 null(首启未签发,或写入失败——后者由自举的读回校验报出来)。 */
|
|
83
|
+
fun loadRecoveryCode(context: Context): String? {
|
|
84
|
+
val prefs = context.getSharedPreferences(PREFS_RECOVERY_NAME, Context.MODE_PRIVATE)
|
|
85
|
+
val ciphertext = prefs.getString(PREFS_RECOVERY_CIPHERTEXT, null) ?: return null
|
|
86
|
+
val iv = prefs.getString(PREFS_RECOVERY_IV, null) ?: return null
|
|
87
|
+
return try {
|
|
88
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
89
|
+
cipher.init(
|
|
90
|
+
Cipher.DECRYPT_MODE,
|
|
91
|
+
loadOrCreateKey(),
|
|
92
|
+
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(iv, Base64.NO_WRAP)),
|
|
93
|
+
)
|
|
94
|
+
String(cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)), Charsets.UTF_8)
|
|
95
|
+
} catch (error: Exception) {
|
|
96
|
+
android.util.Log.e("adep-bridge", "恢复码解密失败", error)
|
|
97
|
+
null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 写恢复码(覆盖旧值)。失败直接抛——调用方 [AdepBridge] 会转成 `false`,
|
|
103
|
+
* 由自举判定「本次不落 ownerKey」(恢复码丢了不能只留日志)。
|
|
104
|
+
*/
|
|
105
|
+
fun saveRecoveryCode(context: Context, code: String) {
|
|
106
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
107
|
+
cipher.init(Cipher.ENCRYPT_MODE, loadOrCreateKey())
|
|
108
|
+
val ciphertext = cipher.doFinal(code.toByteArray(Charsets.UTF_8))
|
|
109
|
+
context.getSharedPreferences(PREFS_RECOVERY_NAME, Context.MODE_PRIVATE).edit()
|
|
110
|
+
.putString(PREFS_RECOVERY_CIPHERTEXT, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
|
111
|
+
.putString(PREFS_RECOVERY_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
|
112
|
+
.apply()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 删恢复码(认领成功后清理本地绑定等场景)。 */
|
|
116
|
+
fun deleteRecoveryCode(context: Context) {
|
|
117
|
+
context.getSharedPreferences(PREFS_RECOVERY_NAME, Context.MODE_PRIVATE).edit().clear().apply()
|
|
118
|
+
}
|
|
119
|
+
|
|
66
120
|
private fun loadOrCreateKey(): SecretKey {
|
|
67
121
|
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
68
122
|
(store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|