@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.
- package/CHANGELOG.md +20 -0
- package/README.md +251 -2
- package/android/build.gradle +40 -0
- package/android/consumer-rules.pro +10 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/dev/authon/reactnative/wallet/AuthonWalletModule.kt +235 -0
- package/android/src/vendor/GENERATED.md +4 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AndroidCapabilityProbe.kt +86 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/AuthonWallet.kt +218 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bitcoin.kt +296 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Bytes.kt +74 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Chains.kt +157 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/CredentialManagerPasskeys.kt +192 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Der.kt +17 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DeviceStore.kt +170 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/DialogWalletApprover.kt +141 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Eip712.kt +176 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Encodings.kt +133 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmAccount.kt +45 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/EvmTransaction.kt +180 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/GfTables.kt +44 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/HdWallet.kt +130 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Http.kt +86 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/JsValues.kt +83 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/KeystoreDeviceStore.kt +236 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Prf.kt +111 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Proof.kt +134 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Shamir.kt +106 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/SignSummary.kt +335 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Solana.kt +195 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Tron.kt +184 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletApi.kt +361 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletCapabilities.kt +91 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletError.kt +74 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletOps.kt +551 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/WalletTypes.kt +78 -0
- package/android/src/vendor/kotlin/dev/authon/sdk/wallet/Xrp.kt +193 -0
- package/build/authonContext.d.ts +19 -0
- package/build/authonContext.d.ts.map +1 -0
- package/build/authonContext.js +20 -0
- package/build/authonContext.js.map +1 -0
- package/build/client.d.ts +92 -0
- package/build/client.d.ts.map +1 -0
- package/build/client.js +431 -0
- package/build/client.js.map +1 -0
- package/build/errors.d.ts +25 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +15 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +7 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/build/native.d.ts +43 -0
- package/build/native.d.ts.map +1 -0
- package/build/native.js +12 -0
- package/build/native.js.map +1 -0
- package/build/types.d.ts +167 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +3 -0
- package/build/types.js.map +1 -0
- package/build/useAuthonWallet.d.ts +25 -0
- package/build/useAuthonWallet.d.ts.map +1 -0
- package/build/useAuthonWallet.js +77 -0
- package/build/useAuthonWallet.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AuthonReactNativeWallet.podspec +41 -0
- package/ios/AuthonWalletModule.swift +233 -0
- package/ios/vendor/AuthonWallet/AuthonWallet.swift +232 -0
- package/ios/vendor/AuthonWallet/Capabilities.swift +149 -0
- package/ios/vendor/AuthonWallet/Chains/Bitcoin.swift +296 -0
- package/ios/vendor/AuthonWallet/Chains/Solana.swift +207 -0
- package/ios/vendor/AuthonWallet/Chains/Tron.swift +181 -0
- package/ios/vendor/AuthonWallet/Chains/XRP.swift +191 -0
- package/ios/vendor/AuthonWallet/Crypto/BIP39Wordlist.swift +262 -0
- package/ios/vendor/AuthonWallet/Crypto/Chains.swift +278 -0
- package/ios/vendor/AuthonWallet/Crypto/HDKey.swift +108 -0
- package/ios/vendor/AuthonWallet/Crypto/Secp256k1.swift +128 -0
- package/ios/vendor/AuthonWallet/Crypto/Shamir.swift +123 -0
- package/ios/vendor/AuthonWallet/EVM/Address.swift +57 -0
- package/ios/vendor/AuthonWallet/EVM/EvmAccount.swift +48 -0
- package/ios/vendor/AuthonWallet/EVM/Transaction.swift +165 -0
- package/ios/vendor/AuthonWallet/EVM/TypedData.swift +193 -0
- package/ios/vendor/AuthonWallet/Support/BigInteger.swift +220 -0
- package/ios/vendor/AuthonWallet/Support/Bytes.swift +89 -0
- package/ios/vendor/AuthonWallet/Support/JSONValue.swift +341 -0
- package/ios/vendor/AuthonWallet/UI/WalletApprover.swift +250 -0
- package/ios/vendor/AuthonWallet/Wallet/Backup.swift +69 -0
- package/ios/vendor/AuthonWallet/Wallet/DeviceStore.swift +351 -0
- package/ios/vendor/AuthonWallet/Wallet/Passkeys.swift +190 -0
- package/ios/vendor/AuthonWallet/Wallet/Proof.swift +143 -0
- package/ios/vendor/AuthonWallet/Wallet/Shares.swift +116 -0
- package/ios/vendor/AuthonWallet/Wallet/Summary.swift +342 -0
- package/ios/vendor/AuthonWallet/Wallet/WalletAPI.swift +361 -0
- package/ios/vendor/AuthonWallet/Wallet/WalletOps.swift +654 -0
- package/ios/vendor/AuthonWallet/WalletError.swift +84 -0
- package/ios/vendor/CKeccak/LICENSE +22 -0
- package/ios/vendor/CKeccak/README.md +30 -0
- package/ios/vendor/CKeccak/byte_order.h +59 -0
- package/ios/vendor/CKeccak/include/ckeccak.h +13 -0
- package/ios/vendor/CKeccak/memzero.c +75 -0
- package/ios/vendor/CKeccak/memzero.h +8 -0
- package/ios/vendor/CKeccak/options.h +99 -0
- package/ios/vendor/CKeccak/ripemd160.c +343 -0
- package/ios/vendor/CKeccak/ripemd160.h +22 -0
- package/ios/vendor/CKeccak/sha3.c +413 -0
- package/ios/vendor/CKeccak/sha3.h +89 -0
- package/ios/vendor/CMonocypher/LICENCE.md +173 -0
- package/ios/vendor/CMonocypher/README.md +27 -0
- package/ios/vendor/CMonocypher/include/monocypher-ed25519.h +140 -0
- package/ios/vendor/CMonocypher/include/monocypher.h +321 -0
- package/ios/vendor/CMonocypher/monocypher-ed25519.c +500 -0
- package/ios/vendor/CMonocypher/monocypher.c +2956 -0
- package/ios/vendor/CSecp256k1/COPYING +19 -0
- package/ios/vendor/CSecp256k1/README.md +35 -0
- package/ios/vendor/CSecp256k1/build/precomputed_ecmult.c +3 -0
- package/ios/vendor/CSecp256k1/build/precomputed_ecmult_gen.c +3 -0
- package/ios/vendor/CSecp256k1/build/secp256k1.c +3 -0
- package/ios/vendor/CSecp256k1/include/secp256k1.h +899 -0
- package/ios/vendor/CSecp256k1/include/secp256k1_preallocated.h +134 -0
- package/ios/vendor/CSecp256k1/include/secp256k1_recovery.h +113 -0
- package/ios/vendor/CSecp256k1/src/assumptions.h +87 -0
- package/ios/vendor/CSecp256k1/src/checkmem.h +102 -0
- package/ios/vendor/CSecp256k1/src/ecdsa.h +21 -0
- package/ios/vendor/CSecp256k1/src/ecdsa_impl.h +304 -0
- package/ios/vendor/CSecp256k1/src/eckey.h +25 -0
- package/ios/vendor/CSecp256k1/src/eckey_impl.h +92 -0
- package/ios/vendor/CSecp256k1/src/ecmult.h +61 -0
- package/ios/vendor/CSecp256k1/src/ecmult_const.h +38 -0
- package/ios/vendor/CSecp256k1/src/ecmult_const_impl.h +399 -0
- package/ios/vendor/CSecp256k1/src/ecmult_gen.h +143 -0
- package/ios/vendor/CSecp256k1/src/ecmult_gen_impl.h +341 -0
- package/ios/vendor/CSecp256k1/src/ecmult_impl.h +853 -0
- package/ios/vendor/CSecp256k1/src/field.h +350 -0
- package/ios/vendor/CSecp256k1/src/field_10x26.h +57 -0
- package/ios/vendor/CSecp256k1/src/field_10x26_impl.h +1232 -0
- package/ios/vendor/CSecp256k1/src/field_5x52.h +62 -0
- package/ios/vendor/CSecp256k1/src/field_5x52_impl.h +522 -0
- package/ios/vendor/CSecp256k1/src/field_5x52_int128_impl.h +274 -0
- package/ios/vendor/CSecp256k1/src/field_impl.h +457 -0
- package/ios/vendor/CSecp256k1/src/group.h +212 -0
- package/ios/vendor/CSecp256k1/src/group_impl.h +974 -0
- package/ios/vendor/CSecp256k1/src/hash.h +44 -0
- package/ios/vendor/CSecp256k1/src/hash_impl.h +299 -0
- package/ios/vendor/CSecp256k1/src/hsort.h +33 -0
- package/ios/vendor/CSecp256k1/src/hsort_impl.h +125 -0
- package/ios/vendor/CSecp256k1/src/int128.h +90 -0
- package/ios/vendor/CSecp256k1/src/int128_impl.h +18 -0
- package/ios/vendor/CSecp256k1/src/int128_native.h +19 -0
- package/ios/vendor/CSecp256k1/src/int128_native_impl.h +94 -0
- package/ios/vendor/CSecp256k1/src/int128_struct.h +14 -0
- package/ios/vendor/CSecp256k1/src/int128_struct_impl.h +205 -0
- package/ios/vendor/CSecp256k1/src/modinv32.h +43 -0
- package/ios/vendor/CSecp256k1/src/modinv32_impl.h +725 -0
- package/ios/vendor/CSecp256k1/src/modinv64.h +47 -0
- package/ios/vendor/CSecp256k1/src/modinv64_impl.h +780 -0
- package/ios/vendor/CSecp256k1/src/modules/recovery/main_impl.h +159 -0
- package/ios/vendor/CSecp256k1/src/precomputed_ecmult.c +16456 -0
- package/ios/vendor/CSecp256k1/src/precomputed_ecmult.h +38 -0
- package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.c +1779 -0
- package/ios/vendor/CSecp256k1/src/precomputed_ecmult_gen.h +26 -0
- package/ios/vendor/CSecp256k1/src/scalar.h +105 -0
- package/ios/vendor/CSecp256k1/src/scalar_4x64.h +19 -0
- package/ios/vendor/CSecp256k1/src/scalar_4x64_impl.h +1000 -0
- package/ios/vendor/CSecp256k1/src/scalar_8x32.h +19 -0
- package/ios/vendor/CSecp256k1/src/scalar_8x32_impl.h +816 -0
- package/ios/vendor/CSecp256k1/src/scalar_impl.h +321 -0
- package/ios/vendor/CSecp256k1/src/scalar_low.h +24 -0
- package/ios/vendor/CSecp256k1/src/scalar_low_impl.h +206 -0
- package/ios/vendor/CSecp256k1/src/scratch.h +44 -0
- package/ios/vendor/CSecp256k1/src/scratch_impl.h +99 -0
- package/ios/vendor/CSecp256k1/src/secp256k1.c +831 -0
- package/ios/vendor/CSecp256k1/src/selftest.h +32 -0
- package/ios/vendor/CSecp256k1/src/util.h +451 -0
- package/ios/vendor/GENERATED.md +4 -0
- package/package.json +66 -3
- package/src/authonContext.ts +30 -0
- package/src/client.ts +459 -0
- package/src/errors.ts +58 -0
- package/src/index.ts +26 -0
- package/src/native.ts +52 -0
- package/src/types.ts +171 -0
- 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
|
+
}
|