@authon/react-native-wallet 0.0.0-stage → 0.4.0

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 (183) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +251 -2
  3. package/android/build.gradle +40 -0
  4. package/android/consumer-rules.pro +10 -0
  5. package/android/src/main/AndroidManifest.xml +8 -0
  6. package/android/src/main/java/dev/authon/reactnative/wallet/AuthonWalletModule.kt +235 -0
  7. package/android/src/vendor/GENERATED.md +4 -0
  8. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AndroidCapabilityProbe.kt +86 -0
  9. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AuthonWallet.kt +218 -0
  10. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bitcoin.kt +296 -0
  11. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bytes.kt +74 -0
  12. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Chains.kt +157 -0
  13. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/CredentialManagerPasskeys.kt +192 -0
  14. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Der.kt +17 -0
  15. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DeviceStore.kt +170 -0
  16. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DialogWalletApprover.kt +141 -0
  17. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Eip712.kt +176 -0
  18. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Encodings.kt +133 -0
  19. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmAccount.kt +45 -0
  20. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmTransaction.kt +180 -0
  21. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/GfTables.kt +44 -0
  22. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/HdWallet.kt +130 -0
  23. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Http.kt +86 -0
  24. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/JsValues.kt +83 -0
  25. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/KeystoreDeviceStore.kt +236 -0
  26. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Prf.kt +111 -0
  27. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Proof.kt +134 -0
  28. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Shamir.kt +106 -0
  29. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/SignSummary.kt +335 -0
  30. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Solana.kt +195 -0
  31. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Tron.kt +184 -0
  32. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletApi.kt +361 -0
  33. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletCapabilities.kt +91 -0
  34. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletError.kt +74 -0
  35. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletOps.kt +551 -0
  36. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletTypes.kt +78 -0
  37. package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Xrp.kt +193 -0
  38. package/build/authonContext.d.ts +19 -0
  39. package/build/authonContext.d.ts.map +1 -0
  40. package/build/authonContext.js +20 -0
  41. package/build/authonContext.js.map +1 -0
  42. package/build/client.d.ts +92 -0
  43. package/build/client.d.ts.map +1 -0
  44. package/build/client.js +431 -0
  45. package/build/client.js.map +1 -0
  46. package/build/errors.d.ts +25 -0
  47. package/build/errors.d.ts.map +1 -0
  48. package/build/errors.js +15 -0
  49. package/build/errors.js.map +1 -0
  50. package/build/index.d.ts +7 -0
  51. package/build/index.d.ts.map +1 -0
  52. package/build/index.js +4 -0
  53. package/build/index.js.map +1 -0
  54. package/build/native.d.ts +43 -0
  55. package/build/native.d.ts.map +1 -0
  56. package/build/native.js +12 -0
  57. package/build/native.js.map +1 -0
  58. package/build/types.d.ts +167 -0
  59. package/build/types.d.ts.map +1 -0
  60. package/build/types.js +3 -0
  61. package/build/types.js.map +1 -0
  62. package/build/useAuthonWallet.d.ts +25 -0
  63. package/build/useAuthonWallet.d.ts.map +1 -0
  64. package/build/useAuthonWallet.js +77 -0
  65. package/build/useAuthonWallet.js.map +1 -0
  66. package/expo-module.config.json +9 -0
  67. package/ios/AuthonReactNativeWallet.podspec +41 -0
  68. package/ios/AuthonWalletModule.swift +233 -0
  69. package/ios/vendor/AuthonWallet/AuthonWallet.swift +232 -0
  70. package/ios/vendor/AuthonWallet/Capabilities.swift +149 -0
  71. package/ios/vendor/AuthonWallet/Chains/Bitcoin.swift +296 -0
  72. package/ios/vendor/AuthonWallet/Chains/Solana.swift +207 -0
  73. package/ios/vendor/AuthonWallet/Chains/Tron.swift +181 -0
  74. package/ios/vendor/AuthonWallet/Chains/XRP.swift +191 -0
  75. package/ios/vendor/AuthonWallet/Crypto/BIP39Wordlist.swift +262 -0
  76. package/ios/vendor/AuthonWallet/Crypto/Chains.swift +278 -0
  77. package/ios/vendor/AuthonWallet/Crypto/HDKey.swift +108 -0
  78. package/ios/vendor/AuthonWallet/Crypto/Secp256k1.swift +128 -0
  79. package/ios/vendor/AuthonWallet/Crypto/Shamir.swift +123 -0
  80. package/ios/vendor/AuthonWallet/EVM/Address.swift +57 -0
  81. package/ios/vendor/AuthonWallet/EVM/EvmAccount.swift +48 -0
  82. package/ios/vendor/AuthonWallet/EVM/Transaction.swift +165 -0
  83. package/ios/vendor/AuthonWallet/EVM/TypedData.swift +193 -0
  84. package/ios/vendor/AuthonWallet/Support/BigInteger.swift +220 -0
  85. package/ios/vendor/AuthonWallet/Support/Bytes.swift +89 -0
  86. package/ios/vendor/AuthonWallet/Support/JSONValue.swift +341 -0
  87. package/ios/vendor/AuthonWallet/UI/WalletApprover.swift +250 -0
  88. package/ios/vendor/AuthonWallet/Wallet/Backup.swift +69 -0
  89. package/ios/vendor/AuthonWallet/Wallet/DeviceStore.swift +351 -0
  90. package/ios/vendor/AuthonWallet/Wallet/Passkeys.swift +190 -0
  91. package/ios/vendor/AuthonWallet/Wallet/Proof.swift +143 -0
  92. package/ios/vendor/AuthonWallet/Wallet/Shares.swift +116 -0
  93. package/ios/vendor/AuthonWallet/Wallet/Summary.swift +342 -0
  94. package/ios/vendor/AuthonWallet/Wallet/WalletAPI.swift +361 -0
  95. package/ios/vendor/AuthonWallet/Wallet/WalletOps.swift +654 -0
  96. package/ios/vendor/AuthonWallet/WalletError.swift +84 -0
  97. package/ios/vendor/CKeccak/LICENSE +22 -0
  98. package/ios/vendor/CKeccak/README.md +30 -0
  99. package/ios/vendor/CKeccak/byte_order.h +59 -0
  100. package/ios/vendor/CKeccak/include/ckeccak.h +13 -0
  101. package/ios/vendor/CKeccak/memzero.c +75 -0
  102. package/ios/vendor/CKeccak/memzero.h +8 -0
  103. package/ios/vendor/CKeccak/options.h +99 -0
  104. package/ios/vendor/CKeccak/ripemd160.c +343 -0
  105. package/ios/vendor/CKeccak/ripemd160.h +22 -0
  106. package/ios/vendor/CKeccak/sha3.c +413 -0
  107. package/ios/vendor/CKeccak/sha3.h +89 -0
  108. package/ios/vendor/CMonocypher/LICENCE.md +173 -0
  109. package/ios/vendor/CMonocypher/README.md +27 -0
  110. package/ios/vendor/CMonocypher/include/monocypher-ed25519.h +140 -0
  111. package/ios/vendor/CMonocypher/include/monocypher.h +321 -0
  112. package/ios/vendor/CMonocypher/monocypher-ed25519.c +500 -0
  113. package/ios/vendor/CMonocypher/monocypher.c +2956 -0
  114. package/ios/vendor/CSecp256k1/COPYING +19 -0
  115. package/ios/vendor/CSecp256k1/README.md +35 -0
  116. package/ios/vendor/CSecp256k1/build/precomputed_ecmult.c +3 -0
  117. package/ios/vendor/CSecp256k1/build/precomputed_ecmult_gen.c +3 -0
  118. package/ios/vendor/CSecp256k1/build/secp256k1.c +3 -0
  119. package/ios/vendor/CSecp256k1/include/secp256k1.h +899 -0
  120. package/ios/vendor/CSecp256k1/include/secp256k1_preallocated.h +134 -0
  121. package/ios/vendor/CSecp256k1/include/secp256k1_recovery.h +113 -0
  122. package/ios/vendor/CSecp256k1/src/assumptions.h +87 -0
  123. package/ios/vendor/CSecp256k1/src/checkmem.h +102 -0
  124. package/ios/vendor/CSecp256k1/src/ecdsa.h +21 -0
  125. package/ios/vendor/CSecp256k1/src/ecdsa_impl.h +304 -0
  126. package/ios/vendor/CSecp256k1/src/eckey.h +25 -0
  127. package/ios/vendor/CSecp256k1/src/eckey_impl.h +92 -0
  128. package/ios/vendor/CSecp256k1/src/ecmult.h +61 -0
  129. package/ios/vendor/CSecp256k1/src/ecmult_const.h +38 -0
  130. package/ios/vendor/CSecp256k1/src/ecmult_const_impl.h +399 -0
  131. package/ios/vendor/CSecp256k1/src/ecmult_gen.h +143 -0
  132. package/ios/vendor/CSecp256k1/src/ecmult_gen_impl.h +341 -0
  133. package/ios/vendor/CSecp256k1/src/ecmult_impl.h +853 -0
  134. package/ios/vendor/CSecp256k1/src/field.h +350 -0
  135. package/ios/vendor/CSecp256k1/src/field_10x26.h +57 -0
  136. package/ios/vendor/CSecp256k1/src/field_10x26_impl.h +1232 -0
  137. package/ios/vendor/CSecp256k1/src/field_5x52.h +62 -0
  138. package/ios/vendor/CSecp256k1/src/field_5x52_impl.h +522 -0
  139. package/ios/vendor/CSecp256k1/src/field_5x52_int128_impl.h +274 -0
  140. package/ios/vendor/CSecp256k1/src/field_impl.h +457 -0
  141. package/ios/vendor/CSecp256k1/src/group.h +212 -0
  142. package/ios/vendor/CSecp256k1/src/group_impl.h +974 -0
  143. package/ios/vendor/CSecp256k1/src/hash.h +44 -0
  144. package/ios/vendor/CSecp256k1/src/hash_impl.h +299 -0
  145. package/ios/vendor/CSecp256k1/src/hsort.h +33 -0
  146. package/ios/vendor/CSecp256k1/src/hsort_impl.h +125 -0
  147. package/ios/vendor/CSecp256k1/src/int128.h +90 -0
  148. package/ios/vendor/CSecp256k1/src/int128_impl.h +18 -0
  149. package/ios/vendor/CSecp256k1/src/int128_native.h +19 -0
  150. package/ios/vendor/CSecp256k1/src/int128_native_impl.h +94 -0
  151. package/ios/vendor/CSecp256k1/src/int128_struct.h +14 -0
  152. package/ios/vendor/CSecp256k1/src/int128_struct_impl.h +205 -0
  153. package/ios/vendor/CSecp256k1/src/modinv32.h +43 -0
  154. package/ios/vendor/CSecp256k1/src/modinv32_impl.h +725 -0
  155. package/ios/vendor/CSecp256k1/src/modinv64.h +47 -0
  156. package/ios/vendor/CSecp256k1/src/modinv64_impl.h +780 -0
  157. package/ios/vendor/CSecp256k1/src/modules/recovery/main_impl.h +159 -0
  158. package/ios/vendor/CSecp256k1/src/precomputed_ecmult.c +16456 -0
  159. package/ios/vendor/CSecp256k1/src/precomputed_ecmult.h +38 -0
  160. package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.c +1779 -0
  161. package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.h +26 -0
  162. package/ios/vendor/CSecp256k1/src/scalar.h +105 -0
  163. package/ios/vendor/CSecp256k1/src/scalar_4x64.h +19 -0
  164. package/ios/vendor/CSecp256k1/src/scalar_4x64_impl.h +1000 -0
  165. package/ios/vendor/CSecp256k1/src/scalar_8x32.h +19 -0
  166. package/ios/vendor/CSecp256k1/src/scalar_8x32_impl.h +816 -0
  167. package/ios/vendor/CSecp256k1/src/scalar_impl.h +321 -0
  168. package/ios/vendor/CSecp256k1/src/scalar_low.h +24 -0
  169. package/ios/vendor/CSecp256k1/src/scalar_low_impl.h +206 -0
  170. package/ios/vendor/CSecp256k1/src/scratch.h +44 -0
  171. package/ios/vendor/CSecp256k1/src/scratch_impl.h +99 -0
  172. package/ios/vendor/CSecp256k1/src/secp256k1.c +831 -0
  173. package/ios/vendor/CSecp256k1/src/selftest.h +32 -0
  174. package/ios/vendor/CSecp256k1/src/util.h +451 -0
  175. package/ios/vendor/GENERATED.md +4 -0
  176. package/package.json +66 -3
  177. package/src/authonContext.ts +30 -0
  178. package/src/client.ts +459 -0
  179. package/src/errors.ts +58 -0
  180. package/src/index.ts +26 -0
  181. package/src/native.ts +52 -0
  182. package/src/types.ts +171 -0
  183. package/src/useAuthonWallet.ts +103 -0
@@ -0,0 +1,551 @@
1
+ // GENERATED by scripts/sync-native-wallet.mjs from kotlin/authon/src/main/kotlin/dev/authon/sdk/wallet/WalletOps.kt. Do not edit: change the source and run the script.
2
+ package dev.authon.sdk.wallet
3
+
4
+ import kotlinx.serialization.json.Json
5
+ import kotlinx.serialization.json.JsonObject
6
+ import kotlinx.serialization.json.JsonPrimitive
7
+
8
+ internal class WalletContext(
9
+ val api: WalletApi,
10
+ val store: DeviceStore,
11
+ val passkeys: PasskeyProvider,
12
+ val projectId: String,
13
+ /** From the project's server-side settings only. */
14
+ val confirmSigning: Boolean = true,
15
+ /** PRF salt version for new backups (server flag). */
16
+ val backupVersion: Int = Prf.DEFAULT_WRITE_BACKUP_VERSION,
17
+ val approver: WalletApprover? = null,
18
+ val deviceAuthenticator: DeviceAuthenticator? = null,
19
+ )
20
+
21
+ internal data class WalletStatus(
22
+ val exists: Boolean,
23
+ val pending: Boolean,
24
+ val hasLocalShare: Boolean,
25
+ val needsRestore: Boolean,
26
+ val deviceId: String?,
27
+ val wallet: ServerWallet?,
28
+ )
29
+
30
+ /** The flows of `apps/wallet/src/ops.ts`, step for step. */
31
+ internal class WalletOps(private val ctx: WalletContext) {
32
+
33
+ fun toPublicWallet(st: WalletStatus): PublicWallet {
34
+ val w = st.wallet
35
+ if (w == null || st.pending) throw walletError(WalletErrorCode.NOT_FOUND)
36
+ return PublicWallet(
37
+ address = w.address,
38
+ protected = w.protected,
39
+ backupUpgradeRecommended = w.protected && w.backupVerifiable == false,
40
+ needsRestore = st.needsRestore,
41
+ devices = w.devices.filter { it.revokedAt == null }.map {
42
+ PublicWallet.Device(it.id, it.label, it.createdAt, it.lastUsedAt, it.id == st.deviceId)
43
+ },
44
+ )
45
+ }
46
+
47
+ private fun recordKey(session: String) = DeviceStorage.storageKey(ctx.projectId, decodeUserId(session))
48
+
49
+ suspend fun status(session: String): WalletStatus {
50
+ val wallet = ctx.api.getMe(session)
51
+ val local = ctx.store.get(recordKey(session))
52
+ val pending = wallet?.status == "pending"
53
+ val device = if (local != null && wallet != null) wallet.devices.firstOrNull { it.id == local.deviceId } else null
54
+ val hasLocal = !pending && local != null && wallet != null && local.walletId == wallet.id && device != null &&
55
+ device.revokedAt == null && (device.shareVersion ?: local.shareVersion) == wallet.shareVersion &&
56
+ local.shareVersion == wallet.shareVersion
57
+ val exists = wallet != null && !pending
58
+ return WalletStatus(exists, pending, hasLocal, exists && !hasLocal, if (hasLocal) local!!.deviceId else null, wallet)
59
+ }
60
+
61
+ private fun openShare(record: DeviceRecord): ByteArray = try {
62
+ record.keys.open(SealedShare(record.iv, record.ciphertext), Bytes.utf8(record.key))
63
+ } catch (e: Exception) {
64
+ throw WalletException(WalletErrorCode.NEEDS_RESTORE, "local share unreadable", e)
65
+ }
66
+
67
+ private suspend fun deviceProof(session: String, record: DeviceRecord): DeviceProof {
68
+ val challenge = ctx.api.challenge(session, record.deviceId)
69
+ val signature = try {
70
+ record.keys.sign(Bytes.utf8(challenge))
71
+ } catch (e: Exception) {
72
+ throw WalletException(WalletErrorCode.NEEDS_RESTORE, "device key unavailable", e)
73
+ }
74
+ return DeviceProof(record.deviceId, challenge, Bytes.toBase64(signature))
75
+ }
76
+
77
+ /** Reads the record back and checks that it opens to [share]; storage that drops writes fails here. */
78
+ private suspend fun verifyPersisted(key: String, deviceId: String, walletId: String, share: ByteArray?): DeviceRecord {
79
+ val stored = ctx.store.get(key)
80
+ if (stored == null || stored.deviceId != deviceId || stored.walletId != walletId) {
81
+ throw walletError(WalletErrorCode.STORAGE_UNAVAILABLE, "device share was not persisted")
82
+ }
83
+ if (share != null) {
84
+ var opened: ByteArray? = null
85
+ try {
86
+ opened = openShare(stored)
87
+ if (!Bytes.sameBytes(opened, share)) throw walletError(WalletErrorCode.STORAGE_UNAVAILABLE, "device share read back differs")
88
+ } catch (e: WalletException) {
89
+ if (e.code == WalletErrorCode.STORAGE_UNAVAILABLE) throw e
90
+ throw walletError(WalletErrorCode.STORAGE_UNAVAILABLE, "device share cannot be read back")
91
+ } finally {
92
+ Bytes.wipe(opened)
93
+ }
94
+ }
95
+ return stored
96
+ }
97
+
98
+ private suspend fun finishPending(session: String, wallet: ServerWallet): Boolean {
99
+ val key = recordKey(session)
100
+ val record = ctx.store.get(key)
101
+ if (record == null || record.walletId != wallet.id || wallet.devices.none { it.id == record.deviceId }) return false
102
+ var share: ByteArray? = null
103
+ try {
104
+ share = openShare(record)
105
+ verifyPersisted(key, record.deviceId, wallet.id, share)
106
+ } catch (_: Exception) {
107
+ return false
108
+ } finally {
109
+ Bytes.wipe(share)
110
+ }
111
+ ctx.api.confirmWallet(session, deviceProof(session, record))
112
+ return true
113
+ }
114
+
115
+ /**
116
+ * Two-phase creation: register address + A (wallet pending), store D, read it back, then
117
+ * confirm with a challenge signed by the stored device key. A pending wallet left behind is
118
+ * confirmed (D is here) or discarded and recreated (D is not).
119
+ */
120
+ suspend fun createWallet(session: String, attempt: Int = 0): WalletStatus {
121
+ val existing = status(session)
122
+ if (existing.exists) return existing
123
+ if (existing.pending && existing.wallet != null) {
124
+ if (finishPending(session, existing.wallet)) return status(session)
125
+ if (attempt > 0) throw walletError(WalletErrorCode.STORAGE_UNAVAILABLE)
126
+ ctx.api.discardPending(session)
127
+ runCatching { ctx.store.delete(recordKey(session)) }
128
+ return createWallet(session, attempt + 1)
129
+ }
130
+
131
+ val key = recordKey(session)
132
+ val secret = HdWallet.generateSecret()
133
+ var shares: HdWallet.SplitShares? = null
134
+ var keys: DeviceKeys? = null
135
+ var stored = false
136
+ var registered = false
137
+ try {
138
+ val address = HdWallet.address(secret)
139
+ shares = HdWallet.splitUnprotected(secret)
140
+ keys = ctx.store.generateKeys()
141
+ val sealed = keys.seal(shares.device, Bytes.utf8(key))
142
+ val bound = ctx.api.proofChallenge(session, "create")
143
+ val proof = Proof.sign(secret, Proof.createTypedData(ctx.projectId, decodeUserId(session), keys.publicJwk, bound))
144
+ val created = ctx.api.createWallet(session, address, Bytes.toBase64(shares.company), keys.publicJwk, proof, bound)
145
+ registered = true
146
+ val record = DeviceRecord(
147
+ key, keys, sealed.iv, sealed.ciphertext, created.deviceId, created.walletId, address, created.shareVersion,
148
+ System.currentTimeMillis(),
149
+ )
150
+ ctx.store.put(record)
151
+ stored = true
152
+ val persisted = verifyPersisted(key, created.deviceId, created.walletId, shares.device)
153
+ ctx.api.confirmWallet(session, deviceProof(session, persisted))
154
+ } catch (e: WalletException) {
155
+ if (!stored) keys?.let { runCatching { ctx.store.discardKeys(it) } }
156
+ if (e.code == WalletErrorCode.ALREADY_EXISTS && attempt == 0) return createWallet(session, attempt + 1)
157
+ if (registered && e.code == WalletErrorCode.STORAGE_UNAVAILABLE) {
158
+ runCatching { ctx.api.discardPending(session) }
159
+ runCatching { ctx.store.delete(key) }
160
+ }
161
+ throw e
162
+ } catch (e: Exception) {
163
+ if (!stored) keys?.let { runCatching { ctx.store.discardKeys(it) } }
164
+ throw e
165
+ } finally {
166
+ Bytes.wipe(secret)
167
+ shares?.wipe()
168
+ }
169
+ return status(session)
170
+ }
171
+
172
+ private class Local(val record: DeviceRecord, val wallet: ServerWallet)
173
+
174
+ private suspend fun requireLocal(session: String): Local {
175
+ val wallet = ctx.api.getMe(session)
176
+ if (wallet == null || wallet.status == "pending") throw walletError(WalletErrorCode.NOT_FOUND)
177
+ val record = ctx.store.get(recordKey(session))
178
+ if (record == null || record.walletId != wallet.id) throw walletError(WalletErrorCode.NEEDS_RESTORE)
179
+ return Local(record, wallet)
180
+ }
181
+
182
+ private suspend fun fetchCompanyShare(session: String, record: DeviceRecord): String =
183
+ ctx.api.releaseShare(session, deviceProof(session, record))
184
+
185
+ private suspend fun approveRequest(summary: SignSummary) {
186
+ val approver = ctx.approver ?: throw walletError(WalletErrorCode.CONFIRM_WINDOW_BLOCKED, "no approval UI is available")
187
+ if (!approver.approve(summary)) throw walletError(WalletErrorCode.USER_CANCELLED)
188
+ }
189
+
190
+ private suspend fun authenticateDevice(reason: String) {
191
+ val auth = ctx.deviceAuthenticator ?: return
192
+ if (!auth.authenticate(reason)) throw walletError(WalletErrorCode.USER_CANCELLED)
193
+ }
194
+
195
+ private fun withAccountRow(summary: SignSummary, account: SignAccount): SignSummary =
196
+ if (account.index == 0L) summary else summary.copy(rows = listOf(SignRow("Account", account.address!!, mono = true)) + summary.rows)
197
+
198
+ private suspend fun <T> withSecret(
199
+ session: String,
200
+ approve: (() -> SignSummary)?,
201
+ target: SignAccount,
202
+ fn: (EvmAccount) -> T,
203
+ ): T {
204
+ val summary = if (approve != null && ctx.confirmSigning) withAccountRow(approve(), target) else null
205
+ val local = requireLocal(session)
206
+ // Approval comes before A is requested, so a rejected request never assembles k.
207
+ if (summary != null) approveRequest(summary)
208
+ if (approve != null) authenticateDevice(summary?.title ?: "Sign")
209
+ val companyB64 = fetchCompanyShare(session, local.record)
210
+ val device = openShare(local.record)
211
+ val company = Bytes.fromBase64(companyB64)
212
+ var secret: ByteArray? = null
213
+ try {
214
+ secret = HdWallet.assemble(device, company)
215
+ val primary = HdWallet.account(secret, 0)
216
+ if (!primary.address.equals(local.wallet.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
217
+ if (target.index == 0L) return fn(primary)
218
+ if (!HdWallet.isHdSecret(secret)) throw walletError(WalletErrorCode.ACCOUNT_UNAVAILABLE, "this wallet has only account 0")
219
+ val account = HdWallet.account(secret, target.index)
220
+ if (!account.address.equals(target.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
221
+ return fn(account)
222
+ } finally {
223
+ Bytes.wipe(secret, device, company)
224
+ }
225
+ }
226
+
227
+ suspend fun accounts(session: String, start: Long, count: Int, chain: WalletChain = WalletChain.EVM): List<WalletAccount> {
228
+ if (!HdWallet.isAccountIndex(start) || count < 1 || !HdWallet.isAccountIndex(start + count - 1)) {
229
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "start..start+count-1 must stay within 0..${HdWallet.MAX_ACCOUNT_INDEX}")
230
+ }
231
+ val local = requireLocal(session)
232
+ val companyB64 = fetchCompanyShare(session, local.record)
233
+ val device = openShare(local.record)
234
+ val company = Bytes.fromBase64(companyB64)
235
+ var secret: ByteArray? = null
236
+ try {
237
+ secret = HdWallet.assemble(device, company)
238
+ if (!HdWallet.address(secret).equals(local.wallet.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
239
+ if (chain != WalletChain.EVM) {
240
+ return Chains.addresses(secret, chain, start, count).mapIndexed { i, a ->
241
+ val publicKey = if (chain == WalletChain.XRP) Bytes.toHex(Chains.secpPublicKey(secret, chain, start + i)).uppercase() else null
242
+ WalletAccount(start + i, Chains.path(chain, start + i), a, chain, publicKey)
243
+ }
244
+ }
245
+ if (!HdWallet.isHdSecret(secret) && (start != 0L || count != 1)) {
246
+ throw walletError(WalletErrorCode.ACCOUNT_UNAVAILABLE, "this wallet has only account 0")
247
+ }
248
+ return HdWallet.addresses(secret, start, count).mapIndexed { i, a -> WalletAccount(start + i, HdWallet.evmPath(start + i), a) }
249
+ } finally {
250
+ Bytes.wipe(secret, device, company)
251
+ }
252
+ }
253
+
254
+ private fun reserved() = walletError(WalletErrorCode.INVALID_REQUEST, "this message is reserved for wallet key proofs and cannot be signed")
255
+
256
+ suspend fun signMessage(session: String, message: WalletMessage, account: SignAccount = SignAccount.PRIMARY): String {
257
+ val (summaryInput, bytes, text) = when (message) {
258
+ is WalletMessage.Text -> Triple(Summaries.Message.Text(message.text), Bytes.utf8(message.text), message.text)
259
+ is WalletMessage.Raw -> {
260
+ if (!Regex("^0x[0-9a-fA-F]*$").matches(message.hex)) {
261
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "message must be a string or {raw}")
262
+ }
263
+ Triple(Summaries.Message.Raw(message.hex), Bytes.fromHex(message.hex), Proof.rawMessageText(message.hex))
264
+ }
265
+ }
266
+ if (text != null && Proof.isReservedMessageText(text)) throw reserved()
267
+ return withSecret(session, { Summaries.message(summaryInput) }, account) { it.signMessage(bytes) }
268
+ }
269
+
270
+ suspend fun signTypedData(session: String, typedData: JsonObject, account: SignAccount = SignAccount.PRIMARY): String {
271
+ fun present(k: String) = typedData[k].let { it != null && it !is kotlinx.serialization.json.JsonNull && !(it is JsonPrimitive && it.isString && it.content.isEmpty()) }
272
+ if (!present("types") || !present("primaryType") || !present("message")) {
273
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "typedData incomplete")
274
+ }
275
+ if (Proof.isReservedTypedData(typedData)) throw reserved()
276
+ val digest = Eip712.hashTypedData(typedData)
277
+ return withSecret(session, { Summaries.typedData(typedData) }, account) { it.signDigest(digest).toHex() }
278
+ }
279
+
280
+ suspend fun signTransaction(session: String, transaction: JsonObject, account: SignAccount = SignAccount.PRIMARY): String {
281
+ val tx = EvmTransaction.parse(transaction)
282
+ return withSecret(session, { Summaries.transaction(tx.fields) }, account) { it.signTransaction(tx) }
283
+ }
284
+
285
+ /**
286
+ * Signs an app-built transaction on a non-EVM chain (NATIVE.md section 12). The summary and the
287
+ * fee-payer check come from the payload before A is requested; after assembly the derived
288
+ * address must equal [address]. Solana, TRON, Bitcoin (mainnet/testnet) and XRP.
289
+ */
290
+ suspend fun signChainTransaction(session: String, chain: WalletChain, account: Long, address: String, payload: Map<String, Any?>): Map<String, String> {
291
+ if (chain == WalletChain.EVM) {
292
+ throw walletError(WalletErrorCode.UNSUPPORTED_TYPE, "signing is not available for chain ${chain.wire}")
293
+ }
294
+ if (!HdWallet.isAccountIndex(account)) {
295
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "account must be an integer 0..${HdWallet.MAX_ACCOUNT_INDEX}")
296
+ }
297
+ if (address.isEmpty()) throw walletError(WalletErrorCode.INVALID_REQUEST, "address of the signing account required")
298
+ val summary: SignSummary
299
+ val sign: (ByteArray) -> Map<String, String>
300
+ if (chain == WalletChain.SOLANA) {
301
+ val messageB64 = payload["message"] as? String ?: throw walletError(WalletErrorCode.INVALID_REQUEST, "payload.message (base64) required")
302
+ val message = Bytes.fromBase64(messageB64)
303
+ val cluster = (payload["cluster"] as? String)?.takeIf { it.length <= 32 }
304
+ summary = Solana.summarize(Solana.parse(message), address, if (cluster != null) "Solana $cluster" else "Solana (network chosen by the app)")
305
+ sign = { secret ->
306
+ val key = Solana.privateKey(secret, account)
307
+ try {
308
+ if (Solana.addressOf(key) != address) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
309
+ mapOf("signature" to Solana.sign(message, key))
310
+ } finally {
311
+ Bytes.wipe(key)
312
+ }
313
+ }
314
+ } else if (chain == WalletChain.BITCOIN || chain == WalletChain.BITCOIN_TESTNET) {
315
+ val psbtB64 = payload["psbt"] as? String ?: throw walletError(WalletErrorCode.INVALID_REQUEST, "payload.psbt (base64) required")
316
+ val psbt = Bitcoin.parse(Bytes.fromBase64(psbtB64))
317
+ summary = Bitcoin.summarize(psbt, address, chain)
318
+ sign = { secret ->
319
+ val key = Chains.secpPrivateKey(secret, chain, account)
320
+ try {
321
+ if (Chains.addressOfKey(chain, key) != address) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
322
+ Bitcoin.sign(psbt, key, chain)
323
+ } finally {
324
+ Bytes.wipe(key)
325
+ }
326
+ }
327
+ } else if (chain == WalletChain.XRP) {
328
+ val hex = payload["txBlobHex"] as? String
329
+ if (hex == null || !Regex("^([0-9a-fA-F]{2})+$").matches(hex)) {
330
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "payload.txBlobHex (encodeForSigning output) required")
331
+ }
332
+ val blob = Bytes.fromHex(hex)
333
+ val tx = Xrp.parse(blob)
334
+ if (tx.account != address) throw walletError(WalletErrorCode.ADDRESS_MISMATCH, "the transaction account is not the signing account")
335
+ summary = Xrp.summarize(tx, address, tx.signingPubKey!!)
336
+ sign = { secret ->
337
+ val key = Chains.secpPrivateKey(secret, WalletChain.XRP, account)
338
+ try {
339
+ val pub = Chains.compressedPublicKey(key)
340
+ if (Xrp.addressOfPublicKey(pub) != address || !pub.contentEquals(tx.signingPubKey!!)) {
341
+ throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
342
+ }
343
+ Xrp.sign(blob, key)
344
+ } finally {
345
+ Bytes.wipe(key)
346
+ }
347
+ }
348
+ } else {
349
+ val hex = payload["rawDataHex"] as? String
350
+ if (hex == null || !Regex("^([0-9a-fA-F]{2})+$").matches(hex)) {
351
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "payload.rawDataHex required")
352
+ }
353
+ val raw = Bytes.fromHex(hex)
354
+ summary = Tron.summarize(Tron.parse(raw), address)
355
+ sign = { secret ->
356
+ val key = Chains.secpPrivateKey(secret, WalletChain.TRON, account)
357
+ try {
358
+ if (Chains.addressOfKey(WalletChain.TRON, key) != address) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
359
+ Tron.sign(raw, key)
360
+ } finally {
361
+ Bytes.wipe(key)
362
+ }
363
+ }
364
+ }
365
+ val local = requireLocal(session)
366
+ if (ctx.confirmSigning) approveRequest(summary)
367
+ authenticateDevice(summary.title)
368
+ val companyB64 = fetchCompanyShare(session, local.record)
369
+ val device = openShare(local.record)
370
+ val company = Bytes.fromBase64(companyB64)
371
+ var secret: ByteArray? = null
372
+ try {
373
+ secret = HdWallet.assemble(device, company)
374
+ if (!HdWallet.address(secret).equals(local.wallet.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
375
+ return sign(secret)
376
+ } finally {
377
+ Bytes.wipe(secret, device, company)
378
+ }
379
+ }
380
+
381
+ private suspend fun writeBackup(session: String, upgrade: Boolean): ProtectResult {
382
+ val local = requireLocal(session)
383
+ val record = local.record
384
+ val wallet = local.wallet
385
+ if (!upgrade && wallet.protected) return ProtectResult(true, wallet.shareVersion)
386
+ if (upgrade) {
387
+ if (!wallet.protected) throw walletError(WalletErrorCode.NOT_PROTECTED)
388
+ if (wallet.backupVerifiable != false) throw walletError(WalletErrorCode.ALREADY_UPGRADED)
389
+ }
390
+ val key = recordKey(session)
391
+ val user = decodeUserId(session)
392
+ val version = ctx.backupVersion
393
+ val salt = Prf.salt(version, ctx.projectId, wallet.id)
394
+ val passkeyUser = Prf.passkeyUserId("${ctx.projectId}:$user")
395
+ val pk = ctx.passkeys.create(passkeyUser, wallet.address, salt)
396
+ var oldDevice: ByteArray? = null
397
+ var oldCompany: ByteArray? = null
398
+ var secret: ByteArray? = null
399
+ var shares: HdWallet.SplitShares? = null
400
+ var backupKey: ByteArray? = null
401
+ try {
402
+ val companyB64 = fetchCompanyShare(session, record)
403
+ oldDevice = openShare(record)
404
+ oldCompany = Bytes.fromBase64(companyB64)
405
+ secret = HdWallet.assemble(oldDevice, oldCompany)
406
+ if (!HdWallet.address(secret).equals(wallet.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
407
+ shares = HdWallet.splitProtected(secret)
408
+ backupKey = Prf.deriveBackupKey(pk.prfOutput)
409
+ val backupCiphertext = Prf.encryptBackup(backupKey, shares.backup!!, wallet.id, version)
410
+ val proof = deviceProof(session, record)
411
+ val keyFields = if (pk.publicKey != null && pk.publicKeyAlg != null) {
412
+ WalletApi.BackupKeyFields(pk.publicKey, pk.publicKeyAlg, null)
413
+ } else {
414
+ WalletApi.BackupKeyFields(null, null, pk.attestationObject)
415
+ }
416
+ val shareVersion = ctx.api.putBackup(
417
+ session, proof, backupCiphertext, pk.credentialId, Bytes.toBase64(shares.company), keyFields, upgrade,
418
+ )
419
+ val sealed = record.keys.seal(shares.device, Bytes.utf8(key))
420
+ ctx.store.put(record.copy(iv = sealed.iv, ciphertext = sealed.ciphertext, shareVersion = shareVersion))
421
+ return ProtectResult(true, shareVersion)
422
+ } finally {
423
+ Bytes.wipe(secret, oldDevice, oldCompany, pk.prfOutput, backupKey)
424
+ shares?.wipe()
425
+ }
426
+ }
427
+
428
+ suspend fun protect(session: String): ProtectResult = writeBackup(session, upgrade = false)
429
+
430
+ suspend fun upgradeBackup(session: String): ProtectResult = writeBackup(session, upgrade = true)
431
+
432
+ suspend fun restore(session: String, mode: RestoreMode = RestoreMode.ADD): RestoreResult {
433
+ val wallet = ctx.api.getMe(session)
434
+ if (wallet == null || wallet.status == "pending") throw walletError(WalletErrorCode.NOT_FOUND)
435
+ val start = ctx.api.restoreStart(session)
436
+ val backupCiphertext = start.backupCiphertext
437
+ val credentialId = start.credentialId
438
+ if (backupCiphertext == null || credentialId == null) throw walletError(WalletErrorCode.NOT_PROTECTED)
439
+
440
+ val key = recordKey(session)
441
+ val version = Prf.backupVersionOf(backupCiphertext)
442
+ val salt = Prf.salt(version, ctx.projectId, wallet.id)
443
+ val challenge = start.passkeyChallenge?.let { Bytes.fromBase64(it) }
444
+ val got = ctx.passkeys.get(credentialId, salt, challenge)
445
+ val prfOutput = got.prfOutput
446
+ var company: ByteArray? = null
447
+ var backup: ByteArray? = null
448
+ var secret: ByteArray? = null
449
+ var shares: HdWallet.SplitShares? = null
450
+ var backupKey: ByteArray? = null
451
+ try {
452
+ backupKey = Prf.deriveBackupKey(prfOutput)
453
+ backup = Prf.decryptBackup(backupKey, backupCiphertext, wallet.id)
454
+ val companyB64 = start.companyShare ?: ctx.api.restoreAssert(session, got.assertion, start.passkeyChallenge!!)
455
+ company = Bytes.fromBase64(companyB64)
456
+ secret = HdWallet.assemble(backup, company)
457
+ if (!HdWallet.address(secret).equals(wallet.address, ignoreCase = true)) throw walletError(WalletErrorCode.ADDRESS_MISMATCH)
458
+
459
+ val upgradeRecommended = start.upgradeRecommended
460
+ if (mode == RestoreMode.ADD) {
461
+ val added = addThisDevice(session, key, wallet, secret, company, backup)
462
+ return RestoreResult(true, added, upgradeRecommended)
463
+ }
464
+
465
+ shares = HdWallet.splitProtected(secret)
466
+ val keys = ctx.store.generateKeys()
467
+ var stored = false
468
+ try {
469
+ val bound = ctx.api.proofChallenge(session, "restore")
470
+ val proof = Proof.sign(secret, Proof.restoreTypedData(wallet.id, wallet.shareVersion, keys.publicJwk, bound))
471
+ // The PRF output belongs to this backup's salt version, so the new backup keeps it.
472
+ val newBackupCiphertext = Prf.encryptBackup(backupKey, shares.backup!!, wallet.id, version)
473
+ val res = ctx.api.restore(
474
+ session, Bytes.toBase64(shares.company), newBackupCiphertext, keys.publicJwk, credentialId, proof, bound,
475
+ )
476
+ val sealed = keys.seal(shares.device, Bytes.utf8(key))
477
+ ctx.store.put(
478
+ DeviceRecord(key, keys, sealed.iv, sealed.ciphertext, res.deviceId, wallet.id, wallet.address, res.shareVersion, System.currentTimeMillis()),
479
+ )
480
+ stored = true
481
+ return RestoreResult(true, res.shareVersion, upgradeRecommended)
482
+ } finally {
483
+ if (!stored) runCatching { ctx.store.discardKeys(keys) }
484
+ }
485
+ } finally {
486
+ Bytes.wipe(prfOutput, company, backup, secret, backupKey)
487
+ shares?.wipe()
488
+ }
489
+ }
490
+
491
+ private suspend fun addThisDevice(
492
+ session: String,
493
+ key: String,
494
+ wallet: ServerWallet,
495
+ secret: ByteArray,
496
+ company: ByteArray,
497
+ backup: ByteArray,
498
+ ): Int {
499
+ val device = HdWallet.deriveShare(company, backup)
500
+ var check1: ByteArray? = null
501
+ var check2: ByteArray? = null
502
+ try {
503
+ check1 = HdWallet.assemble(device, company)
504
+ check2 = HdWallet.assemble(device, backup)
505
+ if (!Bytes.sameBytes(check1, secret) || !Bytes.sameBytes(check2, secret)) {
506
+ throw walletError(WalletErrorCode.INTERNAL, "derived share mismatch")
507
+ }
508
+ val keys = ctx.store.generateKeys()
509
+ var stored = false
510
+ try {
511
+ val bound = ctx.api.proofChallenge(session, "device")
512
+ val proof = Proof.sign(secret, Proof.deviceTypedData(wallet.id, wallet.shareVersion, keys.publicJwk, bound))
513
+ val res = ctx.api.addDevice(session, keys.publicJwk, proof, bound)
514
+ val sealed = keys.seal(device, Bytes.utf8(key))
515
+ ctx.store.put(
516
+ DeviceRecord(key, keys, sealed.iv, sealed.ciphertext, res.deviceId, wallet.id, wallet.address, res.shareVersion, System.currentTimeMillis()),
517
+ )
518
+ stored = true
519
+ return res.shareVersion
520
+ } finally {
521
+ if (!stored) runCatching { ctx.store.discardKeys(keys) }
522
+ }
523
+ } finally {
524
+ Bytes.wipe(device, check1, check2)
525
+ }
526
+ }
527
+
528
+ suspend fun revokeDevice(session: String, deviceId: String): Boolean {
529
+ if (deviceId.isEmpty()) throw walletError(WalletErrorCode.INVALID_REQUEST, "deviceId required")
530
+ val local = requireLocal(session)
531
+ ctx.api.revokeDevice(session, deviceId, deviceProof(session, local.record))
532
+ if (local.record.deviceId == deviceId) ctx.store.delete(recordKey(session))
533
+ return true
534
+ }
535
+
536
+ companion object {
537
+ /** The `sub` claim of the session JWT (no verification; the API verifies the token). */
538
+ fun decodeUserId(session: String): String {
539
+ val part = session.split('.').getOrNull(1)?.takeIf { it.isNotEmpty() }
540
+ ?: throw walletError(WalletErrorCode.UNAUTHORIZED, "malformed session token")
541
+ return try {
542
+ val claims = Json.parseToJsonElement(String(Bytes.fromBase64(part), Charsets.UTF_8)) as JsonObject
543
+ val sub = claims["sub"] as? JsonPrimitive
544
+ if (sub == null || !sub.isString || sub.content.isEmpty()) throw IllegalStateException()
545
+ sub.content
546
+ } catch (_: Exception) {
547
+ throw walletError(WalletErrorCode.UNAUTHORIZED, "malformed session token")
548
+ }
549
+ }
550
+ }
551
+ }
@@ -0,0 +1,78 @@
1
+ // GENERATED by scripts/sync-native-wallet.mjs from kotlin/authon/src/main/kotlin/dev/authon/sdk/wallet/WalletTypes.kt. Do not edit: change the source and run the script.
2
+ package dev.authon.sdk.wallet
3
+
4
+ /** What apps see about the wallet (the frame's PublicWallet). */
5
+ data class PublicWallet(
6
+ val address: String,
7
+ /** False until a passkey backup exists. An unprotected wallet is lost with this app's storage. */
8
+ val protected: Boolean,
9
+ /** True for a legacy backup without a passkey public key; call upgradeBackup() from a device that holds the wallet. */
10
+ val backupUpgradeRecommended: Boolean,
11
+ val needsRestore: Boolean,
12
+ val devices: List<Device>,
13
+ ) {
14
+ data class Device(val id: String, val label: String?, val createdAt: String?, val lastUsedAt: String?, val current: Boolean)
15
+ }
16
+
17
+ data class WalletAccount(
18
+ val index: Long,
19
+ val path: String,
20
+ val address: String,
21
+ val chain: WalletChain = WalletChain.EVM,
22
+ /** XRP: the compressed public key (uppercase hex) the app puts in SigningPubKey. */
23
+ val publicKey: String? = null,
24
+ )
25
+
26
+ /**
27
+ * Which account signs. Account 0 is the wallet address the server knows; another account must
28
+ * name the address the caller expects (from accounts()), and signing is refused when it differs.
29
+ */
30
+ data class SignAccount(val index: Long = 0, val address: String? = null) {
31
+ companion object {
32
+ val PRIMARY = SignAccount()
33
+
34
+ fun parse(index: Long?, address: String?): SignAccount {
35
+ if (index == null || index == 0L) return PRIMARY
36
+ if (!HdWallet.isAccountIndex(index)) {
37
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "account must be an integer 0..${HdWallet.MAX_ACCOUNT_INDEX}")
38
+ }
39
+ if (address == null || !Regex("^0x[0-9a-fA-F]{40}$").matches(address)) {
40
+ throw walletError(WalletErrorCode.INVALID_REQUEST, "address of the signing account required")
41
+ }
42
+ return SignAccount(index, address)
43
+ }
44
+ }
45
+ }
46
+
47
+ sealed class WalletMessage {
48
+ /** UTF-8 text. */
49
+ data class Text(val text: String) : WalletMessage()
50
+
51
+ /** Raw bytes as 0x hex. */
52
+ data class Raw(val hex: String) : WalletMessage()
53
+ }
54
+
55
+ enum class RestoreMode {
56
+ /** Enroll this device next to the existing ones (default). */
57
+ ADD,
58
+
59
+ /** Re-split the key and revoke every other device (lost device). */
60
+ RESET,
61
+ }
62
+
63
+ data class ProtectResult(val protected: Boolean, val shareVersion: Int)
64
+
65
+ data class RestoreResult(val restored: Boolean, val shareVersion: Int, val backupUpgradeRecommended: Boolean)
66
+
67
+ /**
68
+ * Shows a signing request and returns true when the user approves. Called before the company share
69
+ * is requested, so a rejected request never assembles the key.
70
+ */
71
+ fun interface WalletApprover {
72
+ suspend fun approve(summary: SignSummary): Boolean
73
+ }
74
+
75
+ /** Optional extra gate (device lock / biometrics) before any key assembly. Throw or return false to refuse. */
76
+ fun interface DeviceAuthenticator {
77
+ suspend fun authenticate(reason: String): Boolean
78
+ }