@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.
Files changed (46) hide show
  1. package/README.md +107 -11
  2. package/dist/index.js +33802 -27354
  3. package/dist/vite.js +247 -17
  4. package/dist/worker-entry.js +163 -1
  5. package/package.json +7 -6
  6. package/templates/app-desktop/README.md +67 -2
  7. package/templates/app-desktop/identity-store.mjs +309 -0
  8. package/templates/app-desktop/identity.mjs +517 -0
  9. package/templates/app-desktop/main.cjs +17 -0
  10. package/templates/app-desktop/main.mjs +218 -2
  11. package/templates/app-desktop/offline-runtime.mjs +72 -3
  12. package/templates/app-desktop/package.json +10 -6
  13. package/templates/app-desktop/recovery-ui.mjs +300 -0
  14. package/templates/app-desktop/sync-loop.mjs +137 -0
  15. package/templates/app-mobile/README.md +47 -9
  16. package/templates/app-mobile/package.json +2 -1
  17. package/templates/app-mobile/plugins/adep-bridge/Package.swift +5 -1
  18. package/templates/app-mobile/plugins/adep-bridge/android/build.gradle +34 -3
  19. package/templates/app-mobile/plugins/adep-bridge/android/src/main/AndroidManifest.xml +12 -5
  20. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepBridge.kt +198 -12
  21. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepIdentity.kt +342 -0
  22. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepKeystore.kt +56 -2
  23. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqlite.kt +506 -0
  24. package/templates/app-mobile/plugins/adep-bridge/android/src/main/java/com/adep/bridge/AdepSqliteLogic.kt +235 -0
  25. package/templates/app-mobile/plugins/adep-bridge/dist/plugin.js +30 -0
  26. package/templates/app-mobile/plugins/adep-bridge/ios/AdepBridge.podspec +3 -0
  27. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepBridge.swift +221 -9
  28. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepIdentity.swift +500 -0
  29. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepKeychain.swift +69 -17
  30. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepPermissionGate.swift +25 -4
  31. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepShellConfig.swift +54 -0
  32. package/templates/app-mobile/plugins/adep-bridge/ios/Sources/AdepSqlite.swift +614 -0
  33. package/templates/app-mobile/plugins/adep-bridge/package.json +10 -0
  34. package/templates/app-mobile/template.json +12 -3
  35. package/templates/app-mobile/web/adep-bridge.js +103 -9
  36. package/templates/app-mobile/web/offline-runtime.js +230 -0
  37. package/templates/app-mobile-xtool/AdepShell-Info.plist +24 -0
  38. package/templates/app-mobile-xtool/Package.swift +59 -0
  39. package/templates/app-mobile-xtool/README.md +81 -0
  40. package/templates/app-mobile-xtool/Sources/AdepShell/App.swift +36 -0
  41. package/templates/app-mobile-xtool/Sources/AdepShell/ShellViewController.swift +223 -0
  42. package/templates/app-mobile-xtool/adep.permissions.json +4 -0
  43. package/templates/app-mobile-xtool/app/site/.gitkeep +0 -0
  44. package/templates/app-mobile-xtool/template.json +15 -0
  45. package/templates/app-mobile-xtool/xtool.yml +35 -0
  46. package/templates/app-mobile/plugins/adep-bridge/capacitor.plugin.json +0 -10
@@ -0,0 +1,500 @@
1
+ import Foundation
2
+
3
+ /// 安装身份自举(iOS,APP-027;PRD §2.11.5「设备身份与恢复」/ ADR-0008 第 2 条)。
4
+ ///
5
+ /// ## 这一层解决什么问题
6
+ ///
7
+ /// ownerKey 必须由**服务端**签发(本地自造 / 构建期预置都判违规)。服务端侧
8
+ /// `POST /api/v1/projects/:projectId/app-installations` 早已交付(APP-007),但客户端
9
+ /// **从未有过调用方**——于是 `getOwnerKey` 恒返回 null、单主同步(`Bearer <ownerKey>`)
10
+ /// 在真机上不可用、SQLCipher 库 key 恒落硬编码缺省(key 就在二进制里)。
11
+ ///
12
+ /// 症状是「401 / 空分区 / 换了设备还是同一把库钥匙」,全部不像「功能没实现」——
13
+ /// 属于本仓最忌讳的静默塌陷。本文件把「首启签发」这一步补上。
14
+ ///
15
+ /// ## 三个刻意的边界
16
+ ///
17
+ /// 1. **不做在 JS 面**:`dist/plugin.js` 的对象键集合不变(页面拿不到写凭据的通道——
18
+ /// XSS 不能替换 ownerKey 劫持数据分区)。自举只发生在原生侧。
19
+ /// 2. **失败不改变可用性**:断网 / 5xx / 响应形态不符 ⇒ 不写空值、不覆盖既有值、
20
+ /// 只留带**原因码**的日志,下次启动重试。首启失败不得阻止 app 启动(ownerKey 只影响
21
+ /// 同步与库 key,不影响页面加载)。
22
+ /// 3. **幂等**:已有合法 ownerKey ⇒ 不请求(一枚安装只签发一次;分母断言见行为级门)。
23
+ ///
24
+ /// ## 纯逻辑与传输分离(为什么拆这么细)
25
+ ///
26
+ /// 判定(config 解析 / URL 组装 / ownerKey 形态 / 响应解析)与传输(`URLSession`)拆开,
27
+ /// 判定层是 Foundation-only 的纯函数 ⇒ `adep-identity.test.ts` 能把**被测源码原样**编进
28
+ /// 探针真跑(不做镜像实现)。响应解析一旦放宽,「写进去的不是 ownerKey」这种错会一直潜伏到
29
+ /// 同步 401 才暴露。
30
+ public struct AdepIdentityConfig: Equatable {
31
+ public let platformUrl: String
32
+ public let projectSlug: String
33
+
34
+ public init(platformUrl: String, projectSlug: String) {
35
+ self.platformUrl = platformUrl
36
+ self.projectSlug = projectSlug
37
+ }
38
+ }
39
+
40
+ /// 签发响应里客户端真正需要的东西(服务端还回 `partition`,它是 `sha256(ownerKey)`,客户端不用)。
41
+ public struct AdepIdentityIssue: Equatable {
42
+ public let ownerKey: String
43
+ public let installationId: String
44
+ public let recoveryCode: String
45
+
46
+ public init(ownerKey: String, installationId: String, recoveryCode: String) {
47
+ self.ownerKey = ownerKey
48
+ self.installationId = installationId
49
+ self.recoveryCode = recoveryCode
50
+ }
51
+ }
52
+
53
+ /// 自举失败 / 跳过的原因码。日志里必须出现码本身(排障时先看码再看文案)。
54
+ public enum AdepIdentityCode {
55
+ /// 包里没有 `adep.app.json`(CLI 未注入或 cap sync 没同步)。
56
+ public static let configMissing = "IDENTITY_CONFIG_MISSING"
57
+ /// 配置在但字段非法(platformUrl 不是 http(s) / projectSlug 形态不符)。
58
+ public static let configInvalid = "IDENTITY_CONFIG_INVALID"
59
+ /// 传输层失败或非 2xx(断网 / 超时 / 5xx / 429)。
60
+ public static let requestFailed = "IDENTITY_REQUEST_FAILED"
61
+ /// 2xx 但响应形态不符(不是 JSON / 缺 installation / ownerKey 形态不对)。
62
+ public static let responseInvalid = "IDENTITY_RESPONSE_INVALID"
63
+ /// 拿到合法 ownerKey 但落 Keychain 失败。
64
+ public static let storeFailed = "IDENTITY_STORE_FAILED"
65
+ /// 已签发(跳过,未发请求)。
66
+ public static let alreadyIssued = "IDENTITY_ALREADY_ISSUED"
67
+ /// 同一实例重复 start(跳过,未发请求)。
68
+ public static let alreadyStarted = "IDENTITY_ALREADY_STARTED"
69
+ }
70
+
71
+ public struct AdepIdentityFailure: Error, Equatable {
72
+ public let code: String
73
+ public let message: String
74
+
75
+ public init(code: String, message: String) {
76
+ self.code = code
77
+ self.message = message
78
+ }
79
+ }
80
+
81
+ /// 判定层(纯函数,Foundation-only ⇒ 行为级门真跑)。
82
+ ///
83
+ /// ⚠️ 下面几个常量与 `server/domains/appbuild/identity/contract.ts` /
84
+ /// `sync-routes.ts` **逐字对应**(`OWNER_KEY_PREFIX` / `OWNER_KEY_BYTES` /
85
+ /// `APP_PLATFORMS` / 端点路径)。漂移由 `adep-identity.test.ts` 的跨端一致性门对账——
86
+ /// 服务端改路径而客户端还打旧路径,症状是 404(像「服务没部署」)。
87
+ public enum AdepIdentityLogic {
88
+ /// ownerKey 明文前缀(服务端 `OWNER_KEY_PREFIX`)。
89
+ public static let ownerKeyPrefix = "adep_ok_"
90
+ /// ownerKey 随机段 hex 长度(服务端 `OWNER_KEY_BYTES = 32` ⇒ 64 位 hex,256 位熵)。
91
+ public static let ownerKeyHexLength = 64
92
+ /// 签发端点路径(服务端 `sync-routes.ts` 的 `/v1/projects/:projectId/app-installations`,
93
+ /// 平台侧挂在 `/api` 前缀下;`:projectId` 接受 **id 或 slug**,见 `resolveProjectByIdOrSlug`)。
94
+ public static let issuePathPrefix = "/api/v1/projects/"
95
+ public static let issuePathSuffix = "/app-installations"
96
+
97
+ /// ownerKey 形态:前缀 + 64 位**小写** hex(与服务端正则完全同形)。
98
+ /// ⚠️ 这里刻意严格(不宽松地接受大写):宽松会让「服务端换了编码」这类契约漂移
99
+ /// 在客户端静默通过,然后在同步侧 401——错误在最远的地方爆。
100
+ public static func isOwnerKey(_ value: String) -> Bool {
101
+ guard value.hasPrefix(ownerKeyPrefix) else { return false }
102
+ let hex = value.dropFirst(ownerKeyPrefix.count)
103
+ guard hex.count == ownerKeyHexLength else { return false }
104
+ return hex.allSatisfy { ($0 >= "0" && $0 <= "9") || ($0 >= "a" && $0 <= "f") }
105
+ }
106
+
107
+ /// projectSlug 形态(与 `adep.app.json` schema 的 `^[a-z][a-z0-9-]{0,62}$` 同形)。
108
+ /// 之所以在客户端再判一次:拼 URL 时 slug 会成为**路径段**,形态失守就是拼出一个别的端点。
109
+ public static func isProjectSlug(_ value: String) -> Bool {
110
+ guard let first = value.first, first >= "a", first <= "z" else { return false }
111
+ guard value.count <= 63 else { return false }
112
+ for char in value {
113
+ let allowed = (char >= "a" && char <= "z") || (char >= "0" && char <= "9") || char == "-"
114
+ if !allowed { return false }
115
+ }
116
+ return true
117
+ }
118
+
119
+ /// 解析包里(或测试注入的)`adep.app.json`。
120
+ /// 只取自举需要的两个字段;**其余字段不校验**(壳配置的完整校验在 CLI 侧
121
+ /// `app-config.ts`,客户端再抄一份 schema 只会漂移)。
122
+ public static func parseConfig(_ data: Data) throws -> AdepIdentityConfig {
123
+ guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
124
+ throw AdepIdentityFailure(
125
+ code: AdepIdentityCode.configInvalid,
126
+ message: "adep.app.json 不是 JSON 对象"
127
+ )
128
+ }
129
+ guard let rawURL = object["platformUrl"] as? String else {
130
+ throw AdepIdentityFailure(
131
+ code: AdepIdentityCode.configInvalid,
132
+ message: "adep.app.json 缺 platformUrl"
133
+ )
134
+ }
135
+ guard
136
+ let url = URL(string: rawURL),
137
+ let scheme = url.scheme?.lowercased(),
138
+ scheme == "http" || scheme == "https",
139
+ let host = url.host,
140
+ !host.isEmpty
141
+ else {
142
+ throw AdepIdentityFailure(
143
+ code: AdepIdentityCode.configInvalid,
144
+ message: "platformUrl 不是合法的 http(s) URL:\(rawURL)"
145
+ )
146
+ }
147
+ guard let slug = object["projectSlug"] as? String else {
148
+ throw AdepIdentityFailure(
149
+ code: AdepIdentityCode.configInvalid,
150
+ message: "adep.app.json 缺 projectSlug"
151
+ )
152
+ }
153
+ guard isProjectSlug(slug) else {
154
+ throw AdepIdentityFailure(
155
+ code: AdepIdentityCode.configInvalid,
156
+ message: "projectSlug 形态不符(需小写字母开头,仅小写字母/数字/连字符):\(slug)"
157
+ )
158
+ }
159
+ return AdepIdentityConfig(platformUrl: rawURL, projectSlug: slug)
160
+ }
161
+
162
+ /// 组装签发端点 URL。`platformUrl` 常带尾斜杠(`https://adep.top/`)⇒ 先归一,
163
+ /// 否则拼出 `//api/v1/...`(服务端 404,症状像「端点没部署」)。
164
+ public static func issueURL(config: AdepIdentityConfig) throws -> URL {
165
+ guard var components = URLComponents(string: config.platformUrl) else {
166
+ throw AdepIdentityFailure(
167
+ code: AdepIdentityCode.configInvalid,
168
+ message: "platformUrl 无法解析:\(config.platformUrl)"
169
+ )
170
+ }
171
+ var base = components.path
172
+ while base.hasSuffix("/") { base.removeLast() }
173
+ components.path = base + issuePathPrefix + config.projectSlug + issuePathSuffix
174
+ // 平台地址上的 query / fragment 对端点无意义,带上只会污染请求。
175
+ components.query = nil
176
+ components.fragment = nil
177
+ guard let url = components.url else {
178
+ throw AdepIdentityFailure(
179
+ code: AdepIdentityCode.configInvalid,
180
+ message: "签发端点 URL 组装失败(platformUrl: \(config.platformUrl))"
181
+ )
182
+ }
183
+ return url
184
+ }
185
+
186
+ /// 请求体:服务端 body 契约是 `{ platform }`(`t.Object({ platform: t.String() })`,
187
+ /// `additionalProperties: true`)。
188
+ public static func issueBody(platform: String) -> Data {
189
+ let escaped = platform.replacingOccurrences(of: "\\", with: "\\\\")
190
+ .replacingOccurrences(of: "\"", with: "\\\"")
191
+ return Data("{\"platform\":\"\(escaped)\"}".utf8)
192
+ }
193
+
194
+ /// 解析签发响应:`{ installation: { installationId, ownerKey, recoveryCode, partition, platform } }`。
195
+ ///
196
+ /// **形态不符一律抛**(不写「尽量取到的那部分」):ownerKey 是 bearer 凭据,
197
+ /// 把一个空串 / 半截值写进 Keychain,等于把「设备身份」变成「永远 401」的垃圾状态,
198
+ /// 而且**看起来像已签发**(下次启动不再重试)——比直接失败难查得多。
199
+ public static func parseIssue(_ data: Data) throws -> AdepIdentityIssue {
200
+ guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
201
+ throw AdepIdentityFailure(
202
+ code: AdepIdentityCode.responseInvalid,
203
+ message: "签发响应不是 JSON 对象"
204
+ )
205
+ }
206
+ guard let installation = object["installation"] as? [String: Any] else {
207
+ throw AdepIdentityFailure(
208
+ code: AdepIdentityCode.responseInvalid,
209
+ message: "签发响应缺 installation 对象"
210
+ )
211
+ }
212
+ guard let ownerKey = installation["ownerKey"] as? String, isOwnerKey(ownerKey) else {
213
+ throw AdepIdentityFailure(
214
+ code: AdepIdentityCode.responseInvalid,
215
+ message: "installation.ownerKey 形态不符(期望 \(ownerKeyPrefix) + \(ownerKeyHexLength) 位小写 hex)"
216
+ )
217
+ }
218
+ guard let installationId = installation["installationId"] as? String, !installationId.isEmpty else {
219
+ throw AdepIdentityFailure(
220
+ code: AdepIdentityCode.responseInvalid,
221
+ message: "installation.installationId 缺失或为空"
222
+ )
223
+ }
224
+ guard let recoveryCode = installation["recoveryCode"] as? String, !recoveryCode.isEmpty else {
225
+ throw AdepIdentityFailure(
226
+ code: AdepIdentityCode.responseInvalid,
227
+ message: "installation.recoveryCode 缺失或为空"
228
+ )
229
+ }
230
+ return AdepIdentityIssue(
231
+ ownerKey: ownerKey,
232
+ installationId: installationId,
233
+ recoveryCode: recoveryCode
234
+ )
235
+ }
236
+
237
+ /// 恢复码脱敏(日志用)。恢复码形态 `<installationId>.<secret>`:**installationId 不是机密**
238
+ /// (它就是安装行主键,服务端接口里也以明文出现),secret 段才是凭据。
239
+ /// ⇒ 保留 installationId + secret 前 4 位 + 总长度(长度能看出「码被截断」这类畸形)。
240
+ public static func maskRecoveryCode(_ value: String, installationId: String) -> String {
241
+ let head = installationId.isEmpty ? "" : installationId + "."
242
+ let secret = value.hasPrefix(head) ? String(value.dropFirst(head.count)) : value
243
+ let keep = String(secret.prefix(4))
244
+ return "\(head)\(keep)…(len \(value.count))"
245
+ }
246
+ }
247
+
248
+ /// 传输层结果。`status == 0` 表示**没拿到 HTTP 响应**(断网 / DNS / 超时 / TLS),
249
+ /// `error` 带传输层原因;`status != 0` 时看状态码分支。
250
+ public struct AdepIdentityHTTPResult: Equatable {
251
+ public let status: Int
252
+ public let body: Data?
253
+ public let error: String?
254
+
255
+ public init(status: Int, body: Data?, error: String?) {
256
+ self.status = status
257
+ self.body = body
258
+ self.error = error
259
+ }
260
+ }
261
+
262
+ /// 传输协议(可注入 ⇒ 行为级门用假传输驱动全部失败分支,不打真网络)。
263
+ public protocol AdepIdentityTransport {
264
+ func post(url: URL, body: Data, timeout: TimeInterval) -> AdepIdentityHTTPResult
265
+ }
266
+
267
+ /// 生产传输:`URLSession`(不引第三方依赖)。
268
+ ///
269
+ /// ⚠️ **同步阻塞**(信号量等待):自举本身在后台队列上跑(`AdepBridge.load()` 里
270
+ /// `DispatchQueue.global()`),所以这里阻塞的是工作线程,不是主线程。
271
+ public final class AdepURLSessionTransport: AdepIdentityTransport {
272
+ public init() {}
273
+
274
+ /// 回调线程与等待线程之间的结果传递(`URLSession` 的回调在别的队列,
275
+ /// 直接捕获 `var` 在 Swift 6 并发检查下是数据竞争)。
276
+ private final class Box: @unchecked Sendable {
277
+ private let lock = NSLock()
278
+ private var value: AdepIdentityHTTPResult?
279
+ func store(_ result: AdepIdentityHTTPResult) {
280
+ lock.lock()
281
+ value = result
282
+ lock.unlock()
283
+ }
284
+ func load() -> AdepIdentityHTTPResult? {
285
+ lock.lock()
286
+ defer { lock.unlock() }
287
+ return value
288
+ }
289
+ }
290
+
291
+ public func post(url: URL, body: Data, timeout: TimeInterval) -> AdepIdentityHTTPResult {
292
+ var request = URLRequest(url: url, timeoutInterval: timeout)
293
+ request.httpMethod = "POST"
294
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
295
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
296
+ request.httpBody = body
297
+
298
+ let box = Box()
299
+ let semaphore = DispatchSemaphore(value: 0)
300
+ let task = URLSession.shared.dataTask(with: request) { data, response, error in
301
+ defer { semaphore.signal() }
302
+ if let error {
303
+ box.store(AdepIdentityHTTPResult(status: 0, body: nil, error: error.localizedDescription))
304
+ return
305
+ }
306
+ guard let http = response as? HTTPURLResponse else {
307
+ box.store(AdepIdentityHTTPResult(status: 0, body: nil, error: "响应不是 HTTPURLResponse"))
308
+ return
309
+ }
310
+ box.store(AdepIdentityHTTPResult(status: http.statusCode, body: data, error: nil))
311
+ }
312
+ task.resume()
313
+ // 兜底超时比 URLRequest 的 timeoutInterval 略宽:正常情况下 URLSession 先报超时
314
+ // (那样能拿到 `error` 文案),这里只兜「回调永远不来」的极端形态。
315
+ _ = semaphore.wait(timeout: .now() + timeout + 5)
316
+ return box.load() ?? AdepIdentityHTTPResult(status: 0, body: nil, error: "URLSession 回调超时未返回")
317
+ }
318
+ }
319
+
320
+ /// 首启自举:查本地 → (缺则)签发 → 写 Keychain → 通知库换 key。
321
+ ///
322
+ /// 依赖全是注入的闭包(存储、配置、传输、日志、换 key 回调)——生产装配见
323
+ /// `AdepBridge.load()`,行为级门注入内存替身。这样「要不要请求」「写不写」「写什么」
324
+ /// 三条判定都能真跑,而不是靠读源码猜。
325
+ public final class AdepIdentityBootstrap {
326
+ /// 注入面。所有副作用都从这里出,`start()` 自身不碰任何全局状态。
327
+ public struct Environment {
328
+ /// 包里 `adep.app.json` 的字节(缺失返回 nil ⇒ `IDENTITY_CONFIG_MISSING`)。
329
+ public var configData: () -> Data?
330
+ /// 读本地既有 ownerKey(Keychain / Keystore);无则 nil。
331
+ public var storedOwnerKey: () -> String?
332
+ /// 写 ownerKey,返回是否成功(Keychain 的 `SecItemAdd` 失败必须能被看见)。
333
+ public var persistOwnerKey: (String) -> Bool
334
+ /// 读本地既有恢复码(写后读回校验用;APP-028)。无则 nil。
335
+ public var storedRecoveryCode: () -> String?
336
+ /// 写恢复码,返回是否成功。**与 ownerKey 同生命周期**(每次成功签发覆盖两把)。
337
+ public var persistRecoveryCode: (String) -> Bool
338
+ /// 传输(生产 `AdepURLSessionTransport`)。
339
+ public var transport: AdepIdentityTransport
340
+ /// 设备平台(服务端枚举 `ios` / `android` / …)。
341
+ public var platform: String
342
+ /// 单次请求超时(秒)。
343
+ public var timeout: TimeInterval
344
+ /// 日志(生产走 NSLog:SpringBoard 拉起的 app 收不到 stdout)。
345
+ public var log: (String) -> Void
346
+ /// ownerKey 落库后的回调(换库 key / rekey;不改变本类的判定)。
347
+ public var onIssued: (String) -> Void
348
+
349
+ public init(
350
+ configData: @escaping () -> Data?,
351
+ storedOwnerKey: @escaping () -> String?,
352
+ persistOwnerKey: @escaping (String) -> Bool,
353
+ storedRecoveryCode: @escaping () -> String?,
354
+ persistRecoveryCode: @escaping (String) -> Bool,
355
+ transport: AdepIdentityTransport,
356
+ platform: String,
357
+ timeout: TimeInterval = 10,
358
+ log: @escaping (String) -> Void,
359
+ onIssued: @escaping (String) -> Void
360
+ ) {
361
+ self.configData = configData
362
+ self.storedOwnerKey = storedOwnerKey
363
+ self.persistOwnerKey = persistOwnerKey
364
+ self.storedRecoveryCode = storedRecoveryCode
365
+ self.persistRecoveryCode = persistRecoveryCode
366
+ self.transport = transport
367
+ self.platform = platform
368
+ self.timeout = timeout
369
+ self.log = log
370
+ self.onIssued = onIssued
371
+ }
372
+ }
373
+
374
+ public enum Outcome: Equatable {
375
+ /// 签发并落库成功(ownerKey 明文——调用方只用于日志脱敏后的读数,不要外传)。
376
+ case issued(ownerKey: String)
377
+ /// 未发请求:已签发 / 重复启动。
378
+ case skipped(code: String)
379
+ /// 失败(原因码 + 可读详情)。**没有任何本地写入发生**。
380
+ case failed(code: String, detail: String)
381
+ }
382
+
383
+ private let environment: Environment
384
+ private let lock = NSLock()
385
+ private var started = false
386
+
387
+ public init(environment: Environment) {
388
+ self.environment = environment
389
+ }
390
+
391
+ /// 启动一次自举。**幂等**:同一实例重复调用只发一次请求(第二次返回
392
+ /// `.skipped(alreadyStarted)`)。**同步阻塞** ⇒ 调用方必须放到后台队列。
393
+ @discardableResult
394
+ public func start() -> Outcome {
395
+ lock.lock()
396
+ if started {
397
+ lock.unlock()
398
+ environment.log("[adep-identity] \(AdepIdentityCode.alreadyStarted): 已启动过,忽略重复调用")
399
+ return .skipped(code: AdepIdentityCode.alreadyStarted)
400
+ }
401
+ started = true
402
+ lock.unlock()
403
+
404
+ // ① 已有合法 ownerKey ⇒ 一枚安装只签发一次(分母断言:这条路径下请求数必须为 0)。
405
+ if let existing = environment.storedOwnerKey(), !existing.isEmpty {
406
+ if AdepIdentityLogic.isOwnerKey(existing) {
407
+ let masked = String(existing.prefix(AdepIdentityLogic.ownerKeyPrefix.count + 6))
408
+ environment.log("[adep-identity] \(AdepIdentityCode.alreadyIssued): 本地已有 ownerKey(\(masked)…),不重复签发")
409
+ return .skipped(code: AdepIdentityCode.alreadyIssued)
410
+ }
411
+ // 形态不符(历史垃圾值 / 手写调试值):不是可用身份,重新签发覆盖它。
412
+ // 不静默当成「已签发」——那会让 app 永远带着一枚服务端不认的凭据。
413
+ environment.log("[adep-identity] 本地 ownerKey 形态不符(长度 \(existing.count)),将重新签发")
414
+ }
415
+
416
+ // ② 配置(包里 adep.app.json)。缺失与非法分开报:前者是打包问题,后者是配置写错。
417
+ guard let configData = environment.configData() else {
418
+ return fail(AdepIdentityCode.configMissing, detail: "包内缺少 \(AdepShellConfig.configFileName)(候选位置:包根 / site/ / public/)")
419
+ }
420
+ let config: AdepIdentityConfig
421
+ do {
422
+ config = try AdepIdentityLogic.parseConfig(configData)
423
+ } catch let failure as AdepIdentityFailure {
424
+ return fail(failure.code, detail: failure.message)
425
+ } catch {
426
+ return fail(AdepIdentityCode.configInvalid, detail: error.localizedDescription)
427
+ }
428
+ let url: URL
429
+ do {
430
+ url = try AdepIdentityLogic.issueURL(config: config)
431
+ } catch let failure as AdepIdentityFailure {
432
+ return fail(failure.code, detail: failure.message)
433
+ } catch {
434
+ return fail(AdepIdentityCode.configInvalid, detail: error.localizedDescription)
435
+ }
436
+
437
+ // ③ 请求。非 2xx 与传输层失败都落 requestFailed,但 detail 区分(status 0 = 没拿到响应)。
438
+ let result = environment.transport.post(
439
+ url: url,
440
+ body: AdepIdentityLogic.issueBody(platform: environment.platform),
441
+ timeout: environment.timeout
442
+ )
443
+ guard result.status >= 200, result.status < 300 else {
444
+ if let transportError = result.error {
445
+ return fail(AdepIdentityCode.requestFailed, detail: "传输层失败:\(transportError)(url: \(url.absoluteString))")
446
+ }
447
+ let snippet = result.body.flatMap { String(data: $0, encoding: .utf8) }?.prefix(200) ?? ""
448
+ return fail(AdepIdentityCode.requestFailed, detail: "HTTP \(result.status)(url: \(url.absoluteString);响应:\(snippet))")
449
+ }
450
+ guard let body = result.body else {
451
+ return fail(AdepIdentityCode.responseInvalid, detail: "HTTP \(result.status) 但响应体为空")
452
+ }
453
+
454
+ // ④ 解析 + 落库。写失败也算失败:没写进去 = 下次启动还会再签发一次(换来多一枚孤儿安装行)。
455
+ let issue: AdepIdentityIssue
456
+ do {
457
+ issue = try AdepIdentityLogic.parseIssue(body)
458
+ } catch let failure as AdepIdentityFailure {
459
+ return fail(failure.code, detail: failure.message)
460
+ } catch {
461
+ return fail(AdepIdentityCode.responseInvalid, detail: error.localizedDescription)
462
+ }
463
+ // ⑤ 恢复码**先**落库并读回校验(APP-028)。
464
+ //
465
+ // 顺序即不变量:**本地有 ownerKey ⇒ 本地必有与它同源的恢复码**。
466
+ // 反过来(先写 ownerKey)会出现「app 已带着这枚身份跑,用户却永远拿不到恢复码」——
467
+ // 恢复码明文只在这一次响应里存在(服务端只存 `recoveryCodeHash`,任何端点都不重发),
468
+ // 丢了就是该安装的分区永久不可认领。所以宁可不落 ownerKey、下次启动重签。
469
+ guard environment.persistRecoveryCode(issue.recoveryCode) else {
470
+ return fail(
471
+ AdepIdentityCode.storeFailed,
472
+ detail: "恢复码写入 Keychain / Keystore 失败(installationId: \(issue.installationId))——本次不落 ownerKey,下次启动重新签发"
473
+ )
474
+ }
475
+ // 读回校验:`SecItemAdd` 报成功但读不出是真实故障形态(accessible 级别不匹配 /
476
+ // 条目被策略清掉)。只看写返回码会把「码已丢」当成功——正是 APP-028 要消灭的静默。
477
+ guard environment.storedRecoveryCode() == issue.recoveryCode else {
478
+ return fail(
479
+ AdepIdentityCode.storeFailed,
480
+ detail: "恢复码写入后读回不一致(installationId: \(issue.installationId))——本次不落 ownerKey"
481
+ )
482
+ }
483
+ // ⑥ ownerKey 落库(写失败也算失败:没写进去 = 下次启动还会再签发一次,多一枚孤儿安装行)。
484
+ guard environment.persistOwnerKey(issue.ownerKey) else {
485
+ return fail(AdepIdentityCode.storeFailed, detail: "ownerKey 写入 Keychain / Keystore 失败(installationId: \(issue.installationId))")
486
+ }
487
+ environment.log(
488
+ "[adep-identity] 签发成功(installationId: \(issue.installationId), platform: \(environment.platform), "
489
+ + "recoveryCode: \(AdepIdentityLogic.maskRecoveryCode(issue.recoveryCode, installationId: issue.installationId)))"
490
+ )
491
+ environment.onIssued(issue.ownerKey)
492
+ return .issued(ownerKey: issue.ownerKey)
493
+ }
494
+
495
+ /// 失败收口:日志必须带**原因码**(排障顺序:先看码,再看详情),并原样返回给调用方。
496
+ private func fail(_ code: String, detail: String) -> Outcome {
497
+ environment.log("[adep-identity] \(code): \(detail)")
498
+ return .failed(code: code, detail: detail)
499
+ }
500
+ }
@@ -1,20 +1,73 @@
1
1
  import Foundation
2
2
  import Security
3
3
 
4
- /// ownerKey 的 Keychain 存储(iOS,APP-009)。
4
+ /// 设备凭据的 Keychain 存储(iOS,APP-009 / APP-027 / APP-028)。
5
5
  ///
6
- /// PRD §2.11.5「设备身份与恢复」:ownerKey 移动端**只存 Keychain / Keystore**——
6
+ /// PRD §2.11.5「设备身份与恢复」:ownerKey 与**恢复码**在移动端**只存 Keychain / Keystore**——
7
7
  /// 不落 UserDefaults / 沙盒文件 / JS 侧持久化(客户端产物等同公开,明文落盘等于
8
8
  /// 把设备身份直接交给解包者)。Keychain 由系统加密保护,应用卸载后条目随钥匙串
9
9
  /// 条目保留策略由系统管理(本实现用 kSecAttrAccessibleAfterFirstUnlock,设备
10
10
  /// 重启后首次解锁即可读,适合后台同步场景)。
11
+ ///
12
+ /// ## 两个槽位(service 区分)
13
+ ///
14
+ /// - `adep.ownerKey`:分区凭据(同步 Bearer + 库 key 同源)。
15
+ /// - `adep.recoveryCode`:换机 / 重装认领同一分区用(APP-028)。**服务端只存它的摘要,
16
+ /// 明文只在签发响应里出现一次** ⇒ 这里写不进去就是永久丢失,所以自举会做写后读回校验。
17
+ ///
18
+ /// ⚠️ **service / account 名不可改**:已装机设备的 Keychain 里是既有条目,改名 = 读不到
19
+ /// ownerKey ⇒ 库 key 失配 ⇒ 数据全丢(不是「重新签发一次就好」——库是用旧 key 建的)。
11
20
  enum AdepKeychain {
12
- private static let service = "adep.ownerKey"
21
+ private static let ownerService = "adep.ownerKey"
22
+ private static let recoveryService = "adep.recoveryCode"
13
23
  private static let account = "default"
14
24
 
25
+ // MARK: - ownerKey
26
+
15
27
  /// 读 ownerKey;无则返回 nil(首启未签发)。
16
28
  static func loadOwnerKey() -> String? {
17
- var query = baseQuery()
29
+ load(service: ownerService)
30
+ }
31
+
32
+ /// 写 ownerKey(已存在则覆盖)。
33
+ @discardableResult
34
+ static func saveOwnerKey(_ key: String) -> Bool {
35
+ save(key, service: ownerService)
36
+ }
37
+
38
+ /// 删 ownerKey(换装 / 恢复码认领时用)。
39
+ @discardableResult
40
+ static func deleteOwnerKey() -> Bool {
41
+ delete(service: ownerService)
42
+ }
43
+
44
+ // MARK: - 恢复码(APP-028)
45
+
46
+ /// 读恢复码;无则 nil(首启未签发,或签发时写入失败——后者由自举的读回校验报出来)。
47
+ static func loadRecoveryCode() -> String? {
48
+ load(service: recoveryService)
49
+ }
50
+
51
+ /// 写恢复码(已存在则覆盖)。
52
+ ///
53
+ /// **覆盖而不是 write-once**:恢复码与 ownerKey 同属一次签发;本地 ownerKey 形态不符会重签
54
+ /// ⇒ 新安装 / 新恢复码。此时保留旧码会让用户认领到**旧分区**(数据是旧的、当前 app 也不用它),
55
+ /// 比「没有码」更难发现。语义与 `saveOwnerKey` 保持一致。
56
+ @discardableResult
57
+ static func saveRecoveryCode(_ code: String) -> Bool {
58
+ save(code, service: recoveryService)
59
+ }
60
+
61
+ /// 删恢复码(认领成功后清理本地绑定等场景)。
62
+ @discardableResult
63
+ static func deleteRecoveryCode() -> Bool {
64
+ delete(service: recoveryService)
65
+ }
66
+
67
+ // MARK: - 内部
68
+
69
+ private static func load(service: String) -> String? {
70
+ var query = baseQuery(service: service)
18
71
  query[kSecReturnData as String] = true
19
72
  query[kSecMatchLimit as String] = kSecMatchLimitOne
20
73
  var item: CFTypeRef?
@@ -23,22 +76,21 @@ enum AdepKeychain {
23
76
  return String(data: data, encoding: .utf8)
24
77
  }
25
78
 
26
- /// 写 ownerKey(已存在则覆盖)。
27
- @discardableResult
28
- static func saveOwnerKey(_ key: String) -> Bool {
29
- deleteOwnerKey()
30
- var query = baseQuery()
31
- query[kSecValueData as String] = Data(key.utf8)
79
+ /// 写(delete + add)。刻意「先删再写」而不是 `SecItemUpdate`:两条路径的返回值语义不同
80
+ /// (update 在条目不存在时是 errSecItemNotFound),混用会把「没写成」判成成功。
81
+ private static func save(_ value: String, service: String) -> Bool {
82
+ _ = delete(service: service)
83
+ var query = baseQuery(service: service)
84
+ query[kSecValueData as String] = Data(value.utf8)
32
85
  return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
33
86
  }
34
87
 
35
- /// 删 ownerKey(换装 / 恢复码认领时用)。
36
- @discardableResult
37
- static func deleteOwnerKey() -> Bool {
38
- SecItemDelete(baseQuery() as CFDictionary) == errSecSuccess || errSecItemNotFoundCheck()
88
+ private static func delete(service: String) -> Bool {
89
+ SecItemDelete(baseQuery(service: service) as CFDictionary) == errSecSuccess
90
+ || errSecItemNotFoundCheck(service: service)
39
91
  }
40
92
 
41
- private static func baseQuery() -> [String: Any] {
93
+ private static func baseQuery(service: String) -> [String: Any] {
42
94
  return [
43
95
  kSecClass as String: kSecClassGenericPassword,
44
96
  kSecAttrService as String: service,
@@ -48,9 +100,9 @@ enum AdepKeychain {
48
100
  }
49
101
 
50
102
  /// delete 在「无条目」时返回 errSecItemNotFound,等同成功(幂等删除)。
51
- private static func errSecItemNotFoundCheck() -> Bool {
103
+ private static func errSecItemNotFoundCheck(service: String) -> Bool {
52
104
  // 上一条删除若因条目不存在失败,这里重查一次确认「确实不存在」即可算成功。
53
- var query = baseQuery()
105
+ var query = baseQuery(service: service)
54
106
  query[kSecReturnData as String] = true
55
107
  var item: CFTypeRef?
56
108
  return SecItemCopyMatching(query as CFDictionary, &item) == errSecItemNotFound