@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,654 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-native-wallet.mjs from swift/Sources/AuthonWallet/Wallet/WalletOps.swift. Do not edit: change the source and run the script.
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public struct WalletStatus: Equatable, Sendable {
|
|
5
|
+
public var exists: Bool
|
|
6
|
+
/// The server has a wallet that was never confirmed (device share not persisted).
|
|
7
|
+
public var pending: Bool
|
|
8
|
+
public var hasLocalShare: Bool
|
|
9
|
+
public var needsRestore: Bool
|
|
10
|
+
public var deviceId: String?
|
|
11
|
+
public var wallet: ServerWallet?
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// What apps see (ops.ts toPublicWallet).
|
|
15
|
+
public struct PublicWallet: Equatable, Sendable {
|
|
16
|
+
public struct Device: Equatable, Sendable {
|
|
17
|
+
public var id: String
|
|
18
|
+
public var label: String?
|
|
19
|
+
public var createdAt: String?
|
|
20
|
+
public var lastUsedAt: String?
|
|
21
|
+
public var current: Bool
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public var address: String
|
|
25
|
+
/// False until a passkey backup exists. An unprotected wallet is lost with this device.
|
|
26
|
+
public var isProtected: Bool
|
|
27
|
+
/// A legacy backup without a passkey public key; call upgradeBackup() from a device that holds the wallet.
|
|
28
|
+
public var backupUpgradeRecommended: Bool
|
|
29
|
+
public var needsRestore: Bool
|
|
30
|
+
public var devices: [Device]
|
|
31
|
+
|
|
32
|
+
init(_ st: WalletStatus) throws {
|
|
33
|
+
guard let w = st.wallet, !st.pending else { throw WalletError(.notFound) }
|
|
34
|
+
address = w.address
|
|
35
|
+
isProtected = w.isProtected
|
|
36
|
+
backupUpgradeRecommended = w.isProtected && w.backupVerifiable == false
|
|
37
|
+
needsRestore = st.needsRestore
|
|
38
|
+
devices = w.devices.filter { $0.revokedAt == nil }.map {
|
|
39
|
+
Device(id: $0.id, label: $0.label, createdAt: $0.createdAt, lastUsedAt: $0.lastUsedAt, current: $0.id == st.deviceId)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public struct WalletAccount: Equatable, Sendable {
|
|
45
|
+
public var index: Int
|
|
46
|
+
public var path: String
|
|
47
|
+
public var address: String
|
|
48
|
+
public var chain: WalletChain
|
|
49
|
+
/// XRP only: the compressed public key (uppercase hex) to put in SigningPubKey.
|
|
50
|
+
public var publicKey: String? = nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Which account signs. Account 0 is the wallet address the server knows; another account
|
|
54
|
+
/// must name the address the caller expects, and signing is refused when it differs.
|
|
55
|
+
public struct SignAccount: Equatable, Sendable {
|
|
56
|
+
public var index: Int
|
|
57
|
+
public var address: String?
|
|
58
|
+
|
|
59
|
+
public static let primary = SignAccount(index: 0, address: nil)
|
|
60
|
+
|
|
61
|
+
public init(index: Int, address: String?) {
|
|
62
|
+
self.index = index
|
|
63
|
+
self.address = address
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// ops.ts parseSignAccount.
|
|
67
|
+
public static func parse(index: Int?, address: String?) throws -> SignAccount {
|
|
68
|
+
guard let index, index != 0 else { return .primary }
|
|
69
|
+
guard WalletShares.isAccountIndex(index) else {
|
|
70
|
+
throw WalletError(.invalidRequest, "account must be an integer 0..\(WalletShares.maxAccountIndex)")
|
|
71
|
+
}
|
|
72
|
+
guard let address, address.count == 42, address.hasPrefix("0x"), Bytes.isHex(address) else {
|
|
73
|
+
throw WalletError(.invalidRequest, "address of the signing account required")
|
|
74
|
+
}
|
|
75
|
+
return SignAccount(index: index, address: address)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// A personal_sign payload.
|
|
80
|
+
public enum WalletMessage: Equatable, Sendable {
|
|
81
|
+
case text(String)
|
|
82
|
+
/// 0x-prefixed hex bytes.
|
|
83
|
+
case raw(String)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
enum ParsedMessage: Equatable {
|
|
87
|
+
case text(String)
|
|
88
|
+
case raw(String)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public enum RestoreMode: String, Sendable {
|
|
92
|
+
/// Enroll this device on the current shares; other devices keep working.
|
|
93
|
+
case add
|
|
94
|
+
/// Re-split the key and revoke every other device (lost device).
|
|
95
|
+
case reset
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public struct ProtectResult: Equatable, Sendable {
|
|
99
|
+
public var shareVersion: Int
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public struct RestoreResult: Equatable, Sendable {
|
|
103
|
+
public var shareVersion: Int
|
|
104
|
+
public var backupUpgradeRecommended: Bool
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
struct WalletContext {
|
|
108
|
+
var api: WalletAPIClient
|
|
109
|
+
var store: DeviceStore
|
|
110
|
+
var passkeys: PasskeyProvider
|
|
111
|
+
var projectId: String
|
|
112
|
+
/// From the project's server-side settings only.
|
|
113
|
+
var confirmSigning: Bool
|
|
114
|
+
var backupVersion: BackupVersion
|
|
115
|
+
var approver: WalletApprover?
|
|
116
|
+
var deviceAuthenticator: DeviceAuthenticator?
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// The wallet operations, step for step the same as `apps/wallet/src/ops.ts`.
|
|
120
|
+
struct WalletOps {
|
|
121
|
+
let ctx: WalletContext
|
|
122
|
+
|
|
123
|
+
static func decodeUserId(_ session: String) throws -> String {
|
|
124
|
+
let parts = session.split(separator: ".", omittingEmptySubsequences: false)
|
|
125
|
+
guard parts.count > 1, !parts[1].isEmpty else { throw WalletError(.unauthorized, "malformed session token") }
|
|
126
|
+
guard let bytes = Bytes.fromBase64(String(parts[1])),
|
|
127
|
+
let claims = try? JSONValue.parse(String(decoding: bytes, as: UTF8.self)),
|
|
128
|
+
case .string(let sub) = claims["sub"] ?? .null, !sub.isEmpty else {
|
|
129
|
+
throw WalletError(.unauthorized, "malformed session token")
|
|
130
|
+
}
|
|
131
|
+
return sub
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func recordKey(_ session: String) throws -> String {
|
|
135
|
+
storageKey(projectId: ctx.projectId, userId: try Self.decodeUserId(session))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func status(_ session: String) async throws -> WalletStatus {
|
|
139
|
+
let wallet = try await ctx.api.getMe(session)
|
|
140
|
+
let local = try ctx.store.get(try recordKey(session))
|
|
141
|
+
let pending = wallet?.status == "pending"
|
|
142
|
+
// A local share only counts while the server still trusts this device at the current share version.
|
|
143
|
+
var hasLocal = false
|
|
144
|
+
if !pending, let local, let wallet, local.walletId == wallet.id,
|
|
145
|
+
let device = wallet.devices.first(where: { $0.id == local.deviceId }), device.revokedAt == nil {
|
|
146
|
+
hasLocal = (device.shareVersion ?? local.shareVersion) == wallet.shareVersion && local.shareVersion == wallet.shareVersion
|
|
147
|
+
}
|
|
148
|
+
let exists = wallet != nil && !pending
|
|
149
|
+
return WalletStatus(
|
|
150
|
+
exists: exists,
|
|
151
|
+
pending: pending,
|
|
152
|
+
hasLocalShare: hasLocal,
|
|
153
|
+
needsRestore: exists && !hasLocal,
|
|
154
|
+
deviceId: hasLocal ? local?.deviceId : nil,
|
|
155
|
+
wallet: wallet
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private func deviceProof(_ session: String, _ record: DeviceRecord) async throws -> DeviceProof {
|
|
160
|
+
let challenge = try await ctx.api.challenge(session, deviceId: record.deviceId)
|
|
161
|
+
let signature = Bytes.base64(try ctx.store.sign(challenge: challenge, record: record))
|
|
162
|
+
return DeviceProof(deviceId: record.deviceId, challenge: challenge, signature: signature)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Reads the record back and checks it opens to `share`; storage that drops writes fails here.
|
|
166
|
+
@discardableResult
|
|
167
|
+
private func verifyPersisted(_ key: String, deviceId: String, walletId: String, share: [UInt8]?) throws -> DeviceRecord {
|
|
168
|
+
guard let stored = try? ctx.store.get(key), stored.deviceId == deviceId, stored.walletId == walletId else {
|
|
169
|
+
throw WalletError(.storageUnavailable, "device share was not persisted")
|
|
170
|
+
}
|
|
171
|
+
if let share {
|
|
172
|
+
var opened: [UInt8]
|
|
173
|
+
do {
|
|
174
|
+
opened = try ctx.store.open(stored)
|
|
175
|
+
} catch {
|
|
176
|
+
throw WalletError(.storageUnavailable, "device share cannot be read back")
|
|
177
|
+
}
|
|
178
|
+
defer { opened.wipe() }
|
|
179
|
+
guard opened == share else { throw WalletError(.storageUnavailable, "device share read back differs") }
|
|
180
|
+
}
|
|
181
|
+
return stored
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Confirms a pending wallet whose device record is present and readable here.
|
|
185
|
+
private func finishPending(_ session: String, _ wallet: ServerWallet) async throws -> Bool {
|
|
186
|
+
let key = try recordKey(session)
|
|
187
|
+
guard let record = try? ctx.store.get(key), record.walletId == wallet.id,
|
|
188
|
+
wallet.devices.contains(where: { $0.id == record.deviceId }) else { return false }
|
|
189
|
+
do {
|
|
190
|
+
var share = try ctx.store.open(record)
|
|
191
|
+
defer { share.wipe() }
|
|
192
|
+
try verifyPersisted(key, deviceId: record.deviceId, walletId: wallet.id, share: share)
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
try await ctx.api.confirmWallet(session, try await deviceProof(session, record))
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/// Two-phase creation: register address and A (pending), store D and read it back, then
|
|
201
|
+
/// confirm with a challenge signed by the stored device key. A pending wallet left by an
|
|
202
|
+
/// interrupted attempt is confirmed (D is here) or discarded and recreated (D is not).
|
|
203
|
+
func create(_ session: String, attempt: Int = 0) async throws -> WalletStatus {
|
|
204
|
+
let existing = try await status(session)
|
|
205
|
+
if existing.exists { return existing }
|
|
206
|
+
if existing.pending, let w = existing.wallet {
|
|
207
|
+
if try await finishPending(session, w) { return try await status(session) }
|
|
208
|
+
if attempt > 0 { throw WalletError(.storageUnavailable) }
|
|
209
|
+
try await ctx.api.discardPending(session)
|
|
210
|
+
try? ctx.store.delete(try recordKey(session))
|
|
211
|
+
return try await create(session, attempt: attempt + 1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let key = try recordKey(session)
|
|
215
|
+
let userId = try Self.decodeUserId(session)
|
|
216
|
+
var secret = try WalletShares.generateSecret()
|
|
217
|
+
var device = [UInt8]()
|
|
218
|
+
var company = [UInt8]()
|
|
219
|
+
var registered = false
|
|
220
|
+
var keys: DeviceKeys?
|
|
221
|
+
var stored = false
|
|
222
|
+
defer {
|
|
223
|
+
secret.wipe()
|
|
224
|
+
device.wipe()
|
|
225
|
+
company.wipe()
|
|
226
|
+
}
|
|
227
|
+
do {
|
|
228
|
+
let address = try WalletShares.address(secret)
|
|
229
|
+
(device, company) = try WalletShares.splitUnprotected(secret)
|
|
230
|
+
let k = try ctx.store.generateKeys()
|
|
231
|
+
keys = k
|
|
232
|
+
let sealed = try ctx.store.seal(device, keys: k, storageKey: key)
|
|
233
|
+
let bound = try await ctx.api.proofChallenge(session, kind: .create)
|
|
234
|
+
let proof = try WalletProof.signCreate(secret, projectId: ctx.projectId, userId: userId, deviceKey: k.publicJwk, bound: bound)
|
|
235
|
+
let created = try await ctx.api.createWallet(session, address: address, companyShare: Bytes.base64(company), devicePublicKey: k.publicJwk, proof: proof, bound: bound)
|
|
236
|
+
registered = true
|
|
237
|
+
let record = DeviceRecord(
|
|
238
|
+
key: key, deviceId: created.deviceId, walletId: created.walletId, address: address,
|
|
239
|
+
shareVersion: created.shareVersion, createdAt: Date().timeIntervalSince1970 * 1000,
|
|
240
|
+
publicJwk: k.publicJwk, keyHandle: k.handle, sealedShare: sealed
|
|
241
|
+
)
|
|
242
|
+
do {
|
|
243
|
+
try ctx.store.put(record)
|
|
244
|
+
} catch {
|
|
245
|
+
throw WalletError(.storageUnavailable, "device share was not persisted")
|
|
246
|
+
}
|
|
247
|
+
stored = true
|
|
248
|
+
let readBack = try verifyPersisted(key, deviceId: created.deviceId, walletId: created.walletId, share: device)
|
|
249
|
+
try await ctx.api.confirmWallet(session, try await deviceProof(session, readBack))
|
|
250
|
+
} catch {
|
|
251
|
+
if !stored, let keys { ctx.store.discardKeys(keys) }
|
|
252
|
+
if let e = error as? WalletError, e.code == .alreadyExists, attempt == 0 {
|
|
253
|
+
return try await create(session, attempt: attempt + 1)
|
|
254
|
+
}
|
|
255
|
+
if registered, let e = error as? WalletError, e.code == .storageUnavailable {
|
|
256
|
+
try? await ctx.api.discardPending(session)
|
|
257
|
+
try? ctx.store.delete(key)
|
|
258
|
+
}
|
|
259
|
+
throw error
|
|
260
|
+
}
|
|
261
|
+
return try await status(session)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func requireLocal(_ session: String) async throws -> (record: DeviceRecord, wallet: ServerWallet) {
|
|
265
|
+
guard let wallet = try await ctx.api.getMe(session), wallet.status != "pending" else { throw WalletError(.notFound) }
|
|
266
|
+
guard let record = try ctx.store.get(try recordKey(session)), record.walletId == wallet.id else {
|
|
267
|
+
throw WalletError(.needsRestore)
|
|
268
|
+
}
|
|
269
|
+
return (record, wallet)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func fetchCompanyShare(_ session: String, _ record: DeviceRecord) async throws -> [UInt8] {
|
|
273
|
+
let b64 = try await ctx.api.releaseShare(session, try await deviceProof(session, record))
|
|
274
|
+
guard let bytes = Bytes.fromBase64(b64) else { throw WalletError(.apiError) }
|
|
275
|
+
return bytes
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// The user's approval for a signature, before A is requested. No approver means no signature.
|
|
279
|
+
private func approve(_ summary: SignSummary) async throws {
|
|
280
|
+
guard let approver = ctx.approver else { throw WalletError(.internal, "confirmation unavailable") }
|
|
281
|
+
try await approver.approveSignature(summary)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func withSecret<T>(
|
|
285
|
+
_ session: String,
|
|
286
|
+
account target: SignAccount,
|
|
287
|
+
summary build: (() throws -> SignSummary)?,
|
|
288
|
+
_ fn: (EvmAccount) throws -> T
|
|
289
|
+
) async throws -> T {
|
|
290
|
+
// Built (and size-checked) only when someone will see it.
|
|
291
|
+
let summary = try build.flatMap { b in ctx.confirmSigning ? Summaries.withAccountRow(try b(), target) : nil }
|
|
292
|
+
let local = try await requireLocal(session)
|
|
293
|
+
if let summary { try await approve(summary) }
|
|
294
|
+
if let auth = ctx.deviceAuthenticator { try await auth.authenticate(reason: summary?.title ?? "Sign with your wallet") }
|
|
295
|
+
var company = try await fetchCompanyShare(session, local.record)
|
|
296
|
+
var device = try ctx.store.open(local.record)
|
|
297
|
+
var secret = [UInt8]()
|
|
298
|
+
defer {
|
|
299
|
+
secret.wipe()
|
|
300
|
+
device.wipe()
|
|
301
|
+
company.wipe()
|
|
302
|
+
}
|
|
303
|
+
secret = try WalletShares.assemble(device, company)
|
|
304
|
+
let primary = try WalletShares.account(secret)
|
|
305
|
+
defer { primary.wipe() }
|
|
306
|
+
guard primary.address.lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
|
|
307
|
+
if target.index == 0 { return try fn(primary) }
|
|
308
|
+
guard WalletShares.isHdSecret(secret) else { throw WalletError(.accountUnavailable, "this wallet has only account 0") }
|
|
309
|
+
let account = try WalletShares.account(secret, index: target.index)
|
|
310
|
+
defer { account.wipe() }
|
|
311
|
+
guard account.address.lowercased() == target.address?.lowercased() else { throw WalletError(.addressMismatch) }
|
|
312
|
+
return try fn(account)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func accounts(_ session: String, start: Int = 0, count: Int = 1, chain: WalletChain = .evm) async throws -> [WalletAccount] {
|
|
316
|
+
guard WalletShares.isAccountIndex(start), count >= 1, WalletShares.isAccountIndex(start + count - 1) else {
|
|
317
|
+
throw WalletError(.invalidRequest, "start..start+count-1 must stay within 0..\(WalletShares.maxAccountIndex)")
|
|
318
|
+
}
|
|
319
|
+
let local = try await requireLocal(session)
|
|
320
|
+
var company = try await fetchCompanyShare(session, local.record)
|
|
321
|
+
var device = try ctx.store.open(local.record)
|
|
322
|
+
var secret = [UInt8]()
|
|
323
|
+
defer {
|
|
324
|
+
secret.wipe()
|
|
325
|
+
device.wipe()
|
|
326
|
+
company.wipe()
|
|
327
|
+
}
|
|
328
|
+
secret = try WalletShares.assemble(device, company)
|
|
329
|
+
guard try WalletShares.address(secret).lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
|
|
330
|
+
if chain != .evm {
|
|
331
|
+
let list = try ChainAddresses.addresses(secret, chain: chain, start: start, count: count)
|
|
332
|
+
let keys = chain == .xrp ? try ChainAddresses.xrpPublicKeys(secret, start: start, count: count) : nil
|
|
333
|
+
return list.enumerated().map {
|
|
334
|
+
WalletAccount(index: start + $0.offset, path: chain.path(start + $0.offset), address: $0.element, chain: chain, publicKey: keys?[$0.offset])
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if !WalletShares.isHdSecret(secret) && (start != 0 || count != 1) {
|
|
338
|
+
throw WalletError(.accountUnavailable, "this wallet has only account 0")
|
|
339
|
+
}
|
|
340
|
+
let list = try WalletShares.addresses(secret, start: start, count: count)
|
|
341
|
+
return list.enumerated().map { WalletAccount(index: start + $0.offset, path: WalletShares.evmPath(start + $0.offset), address: $0.element, chain: .evm) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private static func reserved() -> WalletError {
|
|
345
|
+
WalletError(.invalidRequest, "this message is reserved for wallet key proofs and cannot be signed")
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static func parseMessage(_ m: WalletMessage) throws -> ParsedMessage {
|
|
349
|
+
switch m {
|
|
350
|
+
case .text(let s): return .text(s)
|
|
351
|
+
case .raw(let hex):
|
|
352
|
+
guard hex.hasPrefix("0x"), Bytes.isHex(hex) else { throw WalletError(.invalidRequest, "message must be a string or {raw}") }
|
|
353
|
+
return .raw(hex)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
func signMessage(_ session: String, _ message: WalletMessage, account: SignAccount = .primary) async throws -> String {
|
|
358
|
+
let parsed = try Self.parseMessage(message)
|
|
359
|
+
let text: String?
|
|
360
|
+
let payload: EvmAccount.Message
|
|
361
|
+
switch parsed {
|
|
362
|
+
case .text(let s):
|
|
363
|
+
text = s
|
|
364
|
+
payload = .text(s)
|
|
365
|
+
case .raw(let hex):
|
|
366
|
+
text = WalletProof.rawMessageText(hex)
|
|
367
|
+
payload = .raw(Bytes.fromHex(hex) ?? [])
|
|
368
|
+
}
|
|
369
|
+
if let text, WalletProof.isReservedMessageText(text) { throw Self.reserved() }
|
|
370
|
+
return try await withSecret(session, account: account, summary: { try Summaries.message(parsed) }) { try $0.signMessage(payload) }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
func signTypedData(_ session: String, _ typedData: JSONValue, account: SignAccount = .primary) async throws -> String {
|
|
374
|
+
guard case .object = typedData else { throw WalletError(.invalidRequest) }
|
|
375
|
+
guard typedData["types"]?.isTruthy == true, typedData["primaryType"]?.isTruthy == true, typedData["message"]?.isTruthy == true else {
|
|
376
|
+
throw WalletError(.invalidRequest, "typedData incomplete")
|
|
377
|
+
}
|
|
378
|
+
if WalletProof.isReservedTypedData(typedData) { throw Self.reserved() }
|
|
379
|
+
let td = try TypedData(typedData)
|
|
380
|
+
// Malformed typed data is refused before anyone is asked to approve it.
|
|
381
|
+
_ = try td.digest()
|
|
382
|
+
return try await withSecret(session, account: account, summary: { try Summaries.typedData(typedData) }) { try $0.signTypedData(td) }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private static let bigintFields = ["value", "gas", "gasPrice", "maxFeePerGas", "maxPriorityFeePerGas", "maxFeePerBlobGas"]
|
|
386
|
+
private static let numberFields = ["nonce", "chainId"]
|
|
387
|
+
|
|
388
|
+
/// ops.ts parseTransaction: numeric fields as BigInt, nonce and chainId as Number.
|
|
389
|
+
static func parseTransaction(_ value: JSONValue) throws -> JSONObject {
|
|
390
|
+
guard var tx = value.objectValue else { throw WalletError(.invalidRequest) }
|
|
391
|
+
for f in bigintFields + numberFields {
|
|
392
|
+
guard let v = tx[f] else { continue }
|
|
393
|
+
guard let n = v.jsBigInt else { throw WalletError(.invalidRequest, "transaction numeric field invalid") }
|
|
394
|
+
tx[f] = .number(n.description)
|
|
395
|
+
}
|
|
396
|
+
guard tx["chainId"] != nil else { throw WalletError(.invalidRequest, "chainId required") }
|
|
397
|
+
return tx
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func signTransaction(_ session: String, _ transaction: JSONValue, account: SignAccount = .primary) async throws -> String {
|
|
401
|
+
let tx = try Self.parseTransaction(transaction)
|
|
402
|
+
let evmTx = try EvmTransaction(tx)
|
|
403
|
+
return try await withSecret(session, account: account, summary: { Summaries.transaction(tx) }) { try $0.signTransaction(evmTx) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// Signs a transaction on a chain other than EVM (section 12; Solana and TRON). The summary is
|
|
407
|
+
/// built from the payload and the expected address before the company share is requested.
|
|
408
|
+
func signChainTransaction(_ session: String, chain: WalletChain, account index: Int, address: String, payload: JSONValue) async throws -> [String: String] {
|
|
409
|
+
guard WalletShares.isAccountIndex(index) else {
|
|
410
|
+
throw WalletError(.invalidRequest, "account must be an integer 0..\(WalletShares.maxAccountIndex)")
|
|
411
|
+
}
|
|
412
|
+
guard !address.isEmpty else { throw WalletError(.invalidRequest, "address of the signing account required") }
|
|
413
|
+
let summary: SignSummary
|
|
414
|
+
let sign: ([UInt8]) throws -> [String: String]
|
|
415
|
+
switch chain {
|
|
416
|
+
case .solana:
|
|
417
|
+
guard case .string(let b64) = payload["message"] ?? .null, let message = Bytes.fromBase64(b64) else {
|
|
418
|
+
throw WalletError(.invalidRequest, "payload.message (base64) required")
|
|
419
|
+
}
|
|
420
|
+
let network: String
|
|
421
|
+
if case .string(let cluster) = payload["cluster"] ?? .null, cluster.utf16.count <= 32 {
|
|
422
|
+
network = "Solana \(cluster)"
|
|
423
|
+
} else {
|
|
424
|
+
network = "Solana (network chosen by the app)"
|
|
425
|
+
}
|
|
426
|
+
summary = try Solana.summarize(try Solana.parse(message), signer: address, network: network)
|
|
427
|
+
sign = { secret in
|
|
428
|
+
var key = try Solana.privateKey(secret, index: index)
|
|
429
|
+
defer { key.wipe() }
|
|
430
|
+
let signed = Solana.sign(message, privateKey: key)
|
|
431
|
+
guard signed.address == address else { throw WalletError(.addressMismatch) }
|
|
432
|
+
return ["signature": signed.signature]
|
|
433
|
+
}
|
|
434
|
+
case .bitcoin, .bitcoinTestnet:
|
|
435
|
+
guard case .string(let b64) = payload["psbt"] ?? .null, let bytes = Bytes.fromBase64(b64) else {
|
|
436
|
+
throw WalletError(.invalidRequest, "payload.psbt (base64) required")
|
|
437
|
+
}
|
|
438
|
+
let psbt = try Bitcoin.parse(bytes)
|
|
439
|
+
summary = try Bitcoin.summarize(psbt, signer: address, chain: chain)
|
|
440
|
+
sign = { secret in
|
|
441
|
+
var key = try ChainAddresses.secpPrivateKey(secret, chain: chain, index: index)
|
|
442
|
+
defer { key.wipe() }
|
|
443
|
+
guard try ChainAddresses.secpAddress(chain, privateKey: key) == address else { throw WalletError(.addressMismatch) }
|
|
444
|
+
return try Bitcoin.sign(psbt, privateKey: key)
|
|
445
|
+
}
|
|
446
|
+
case .xrp:
|
|
447
|
+
guard case .string(let hex) = payload["txBlobHex"] ?? .null, Self.isPlainHex(hex), let blob = Bytes.fromHex(hex) else {
|
|
448
|
+
throw WalletError(.invalidRequest, "payload.txBlobHex (encodeForSigning output) required")
|
|
449
|
+
}
|
|
450
|
+
let tx = try XRP.parse(blob)
|
|
451
|
+
guard tx.account == address else { throw WalletError(.addressMismatch, "the transaction account is not the signing account") }
|
|
452
|
+
summary = try XRP.summarize(tx, signer: address, signerPublicKey: tx.signingPubKey!)
|
|
453
|
+
sign = { secret in
|
|
454
|
+
var key = try ChainAddresses.secpPrivateKey(secret, chain: .xrp, index: index)
|
|
455
|
+
defer { key.wipe() }
|
|
456
|
+
let pub = try Secp256k1.publicKey(of: key, compressed: true)
|
|
457
|
+
guard try XRP.address(publicKey: pub) == address, pub == tx.signingPubKey else { throw WalletError(.addressMismatch) }
|
|
458
|
+
return try XRP.sign(blob, privateKey: key)
|
|
459
|
+
}
|
|
460
|
+
case .evm:
|
|
461
|
+
throw WalletError(.unsupportedType, "use signTransaction for EVM")
|
|
462
|
+
case .tron:
|
|
463
|
+
guard case .string(let hex) = payload["rawDataHex"] ?? .null, Self.isPlainHex(hex), let raw = Bytes.fromHex(hex) else {
|
|
464
|
+
throw WalletError(.invalidRequest, "payload.rawDataHex required")
|
|
465
|
+
}
|
|
466
|
+
summary = try Tron.summarize(try Tron.parse(raw), signer: address)
|
|
467
|
+
sign = { secret in
|
|
468
|
+
var key = try ChainAddresses.secpPrivateKey(secret, chain: .tron, index: index)
|
|
469
|
+
defer { key.wipe() }
|
|
470
|
+
guard try ChainAddresses.secpAddress(.tron, privateKey: key) == address else { throw WalletError(.addressMismatch) }
|
|
471
|
+
return try Tron.sign(raw, privateKey: key)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
let local = try await requireLocal(session)
|
|
475
|
+
if ctx.confirmSigning { try await approve(summary) }
|
|
476
|
+
if let auth = ctx.deviceAuthenticator { try await auth.authenticate(reason: summary.title) }
|
|
477
|
+
var company = try await fetchCompanyShare(session, local.record)
|
|
478
|
+
var device = try ctx.store.open(local.record)
|
|
479
|
+
var secret = [UInt8]()
|
|
480
|
+
defer {
|
|
481
|
+
secret.wipe()
|
|
482
|
+
device.wipe()
|
|
483
|
+
company.wipe()
|
|
484
|
+
}
|
|
485
|
+
secret = try WalletShares.assemble(device, company)
|
|
486
|
+
guard try WalletShares.address(secret).lowercased() == local.wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
|
|
487
|
+
return try sign(secret)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/// /^([0-9a-fA-F]{2})+$/
|
|
491
|
+
static func isPlainHex(_ s: String) -> Bool {
|
|
492
|
+
!s.isEmpty && s.count % 2 == 0 && s.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x41...0x46).contains($0) || (0x61...0x66).contains($0) }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// MARK: - Backup
|
|
496
|
+
|
|
497
|
+
private func writeBackup(_ session: String, upgrade: Bool) async throws -> ProtectResult {
|
|
498
|
+
let (record, wallet) = try await requireLocal(session)
|
|
499
|
+
if !upgrade && wallet.isProtected { return ProtectResult(shareVersion: wallet.shareVersion) }
|
|
500
|
+
if upgrade {
|
|
501
|
+
guard wallet.isProtected else { throw WalletError(.notProtected) }
|
|
502
|
+
guard wallet.backupVerifiable == false else { throw WalletError(.alreadyUpgraded) }
|
|
503
|
+
}
|
|
504
|
+
let key = try recordKey(session)
|
|
505
|
+
let user = try Self.decodeUserId(session)
|
|
506
|
+
let version = ctx.backupVersion
|
|
507
|
+
let salt = WalletBackup.prfSalt(version, projectId: ctx.projectId, walletId: wallet.id)
|
|
508
|
+
let passkeyUser = WalletBackup.passkeyUserId(projectId: ctx.projectId, userId: user)
|
|
509
|
+
if let approver = ctx.approver { try await approver.confirmPasskeyAction(upgrade ? .upgradeBackup : .protect) }
|
|
510
|
+
var pk = try await ctx.passkeys.create(userId: passkeyUser, userName: wallet.address, salt: salt)
|
|
511
|
+
var oldCompany = [UInt8]()
|
|
512
|
+
var oldDevice = [UInt8]()
|
|
513
|
+
var secret = [UInt8]()
|
|
514
|
+
var shares: (device: [UInt8], company: [UInt8], backup: [UInt8]) = ([], [], [])
|
|
515
|
+
defer {
|
|
516
|
+
pk.prfOutput.wipe()
|
|
517
|
+
oldCompany.wipe()
|
|
518
|
+
oldDevice.wipe()
|
|
519
|
+
secret.wipe()
|
|
520
|
+
shares.device.wipe()
|
|
521
|
+
shares.company.wipe()
|
|
522
|
+
shares.backup.wipe()
|
|
523
|
+
}
|
|
524
|
+
oldCompany = try await fetchCompanyShare(session, record)
|
|
525
|
+
oldDevice = try ctx.store.open(record)
|
|
526
|
+
secret = try WalletShares.assemble(oldDevice, oldCompany)
|
|
527
|
+
guard try WalletShares.address(secret).lowercased() == wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
|
|
528
|
+
shares = try WalletShares.splitProtected(secret)
|
|
529
|
+
let backupKey = WalletBackup.deriveKey(prfOutput: pk.prfOutput)
|
|
530
|
+
let backupCiphertext = try WalletBackup.encrypt(key: backupKey, share: shares.backup, walletId: wallet.id, version: version)
|
|
531
|
+
let proof = try await deviceProof(session, record)
|
|
532
|
+
let keyFields: WalletAPIClient.BackupKeyFields
|
|
533
|
+
if let pub = pk.publicKey, let alg = pk.publicKeyAlg {
|
|
534
|
+
keyFields = .init(credentialPublicKey: pub, credentialAlg: alg)
|
|
535
|
+
} else {
|
|
536
|
+
keyFields = .init(attestationObject: pk.attestationObject)
|
|
537
|
+
}
|
|
538
|
+
let shareVersion = try await ctx.api.putBackup(
|
|
539
|
+
session, proof: proof, backupCiphertext: backupCiphertext, credentialId: pk.credentialId,
|
|
540
|
+
newCompanyShare: Bytes.base64(shares.company), key: keyFields, upgrade: upgrade
|
|
541
|
+
)
|
|
542
|
+
let sealed = try ctx.store.seal(shares.device, keys: DeviceKeys(handle: record.keyHandle, publicJwk: record.publicJwk), storageKey: key)
|
|
543
|
+
var updated = record
|
|
544
|
+
updated.sealedShare = sealed
|
|
545
|
+
updated.shareVersion = shareVersion
|
|
546
|
+
try ctx.store.put(updated)
|
|
547
|
+
return ProtectResult(shareVersion: shareVersion)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
func protect(_ session: String) async throws -> ProtectResult { try await writeBackup(session, upgrade: false) }
|
|
551
|
+
|
|
552
|
+
/// Replaces a legacy backup (no passkey public key) from a device that holds the wallet.
|
|
553
|
+
func upgradeBackup(_ session: String) async throws -> ProtectResult { try await writeBackup(session, upgrade: true) }
|
|
554
|
+
|
|
555
|
+
func restore(_ session: String, mode: RestoreMode = .add) async throws -> RestoreResult {
|
|
556
|
+
guard let wallet = try await ctx.api.getMe(session), wallet.status != "pending" else { throw WalletError(.notFound) }
|
|
557
|
+
let start = try await ctx.api.restoreStart(session)
|
|
558
|
+
guard let ciphertext = start.backupCiphertext, !ciphertext.isEmpty, let credentialId = start.credentialId, !credentialId.isEmpty else {
|
|
559
|
+
throw WalletError(.notProtected)
|
|
560
|
+
}
|
|
561
|
+
let key = try recordKey(session)
|
|
562
|
+
let version = WalletBackup.version(of: ciphertext)
|
|
563
|
+
let salt = WalletBackup.prfSalt(version, projectId: ctx.projectId, walletId: wallet.id)
|
|
564
|
+
let challenge = start.passkeyChallenge.flatMap(Bytes.fromBase64)
|
|
565
|
+
if let approver = ctx.approver { try await approver.confirmPasskeyAction(mode == .reset ? .restoreReset : .restore) }
|
|
566
|
+
var got = try await ctx.passkeys.get(credentialId: credentialId, salt: salt, challenge: challenge)
|
|
567
|
+
var company = [UInt8]()
|
|
568
|
+
var backup = [UInt8]()
|
|
569
|
+
var secret = [UInt8]()
|
|
570
|
+
var shares: (device: [UInt8], company: [UInt8], backup: [UInt8]) = ([], [], [])
|
|
571
|
+
defer {
|
|
572
|
+
got.prfOutput.wipe()
|
|
573
|
+
company.wipe()
|
|
574
|
+
backup.wipe()
|
|
575
|
+
secret.wipe()
|
|
576
|
+
shares.device.wipe()
|
|
577
|
+
shares.company.wipe()
|
|
578
|
+
shares.backup.wipe()
|
|
579
|
+
}
|
|
580
|
+
let backupKey = WalletBackup.deriveKey(prfOutput: got.prfOutput)
|
|
581
|
+
backup = try WalletBackup.decrypt(key: backupKey, payload: ciphertext, walletId: wallet.id)
|
|
582
|
+
let companyB64: String
|
|
583
|
+
if let c = start.companyShare, !c.isEmpty {
|
|
584
|
+
companyB64 = c
|
|
585
|
+
} else {
|
|
586
|
+
companyB64 = try await ctx.api.restoreAssert(session, got.assertion, challenge: start.passkeyChallenge ?? "")
|
|
587
|
+
}
|
|
588
|
+
guard let c = Bytes.fromBase64(companyB64) else { throw WalletError(.apiError) }
|
|
589
|
+
company = c
|
|
590
|
+
secret = try WalletShares.assemble(backup, company)
|
|
591
|
+
guard try WalletShares.address(secret).lowercased() == wallet.address.lowercased() else { throw WalletError(.addressMismatch) }
|
|
592
|
+
|
|
593
|
+
let upgradeRecommended = start.upgradeRecommended
|
|
594
|
+
if mode == .add {
|
|
595
|
+
let shareVersion = try await addThisDevice(session, key: key, wallet: wallet, secret: secret, company: company, backup: backup)
|
|
596
|
+
return RestoreResult(shareVersion: shareVersion, backupUpgradeRecommended: upgradeRecommended)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
shares = try WalletShares.splitProtected(secret)
|
|
600
|
+
let keys = try ctx.store.generateKeys()
|
|
601
|
+
var stored = false
|
|
602
|
+
defer { if !stored { ctx.store.discardKeys(keys) } }
|
|
603
|
+
let bound = try await ctx.api.proofChallenge(session, kind: .restore)
|
|
604
|
+
let proof = try WalletProof.signRestore(secret, walletId: wallet.id, currentShareVersion: wallet.shareVersion, deviceKey: keys.publicJwk, bound: bound)
|
|
605
|
+
// The PRF output belongs to this backup's salt version, so the new backup keeps it.
|
|
606
|
+
let newBackup = try WalletBackup.encrypt(key: backupKey, share: shares.backup, walletId: wallet.id, version: version)
|
|
607
|
+
let res = try await ctx.api.restore(
|
|
608
|
+
session, newCompanyShare: Bytes.base64(shares.company), newBackupCiphertext: newBackup,
|
|
609
|
+
newDevicePublicKey: keys.publicJwk, credentialId: credentialId, proof: proof, bound: bound
|
|
610
|
+
)
|
|
611
|
+
let sealed = try ctx.store.seal(shares.device, keys: keys, storageKey: key)
|
|
612
|
+
try ctx.store.put(DeviceRecord(
|
|
613
|
+
key: key, deviceId: res.deviceId, walletId: wallet.id, address: wallet.address, shareVersion: res.shareVersion,
|
|
614
|
+
createdAt: Date().timeIntervalSince1970 * 1000, publicJwk: keys.publicJwk, keyHandle: keys.handle, sealedShare: sealed
|
|
615
|
+
))
|
|
616
|
+
stored = true
|
|
617
|
+
return RestoreResult(shareVersion: res.shareVersion, backupUpgradeRecommended: upgradeRecommended)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private func addThisDevice(_ session: String, key: String, wallet: ServerWallet, secret: [UInt8], company: [UInt8], backup: [UInt8]) async throws -> Int {
|
|
621
|
+
var device = try WalletShares.deriveShare(company, backup)
|
|
622
|
+
var check1 = [UInt8]()
|
|
623
|
+
var check2 = [UInt8]()
|
|
624
|
+
defer {
|
|
625
|
+
device.wipe()
|
|
626
|
+
check1.wipe()
|
|
627
|
+
check2.wipe()
|
|
628
|
+
}
|
|
629
|
+
check1 = try WalletShares.assemble(device, company)
|
|
630
|
+
check2 = try WalletShares.assemble(device, backup)
|
|
631
|
+
guard check1 == secret, check2 == secret else { throw WalletError(.internal, "derived share mismatch") }
|
|
632
|
+
let keys = try ctx.store.generateKeys()
|
|
633
|
+
var stored = false
|
|
634
|
+
defer { if !stored { ctx.store.discardKeys(keys) } }
|
|
635
|
+
let bound = try await ctx.api.proofChallenge(session, kind: .device)
|
|
636
|
+
let proof = try WalletProof.signDevice(secret, walletId: wallet.id, shareVersion: wallet.shareVersion, deviceKey: keys.publicJwk, bound: bound)
|
|
637
|
+
let res = try await ctx.api.addDevice(session, devicePublicKey: keys.publicJwk, proof: proof, bound: bound)
|
|
638
|
+
let sealed = try ctx.store.seal(device, keys: keys, storageKey: key)
|
|
639
|
+
try ctx.store.put(DeviceRecord(
|
|
640
|
+
key: key, deviceId: res.deviceId, walletId: wallet.id, address: wallet.address, shareVersion: res.shareVersion,
|
|
641
|
+
createdAt: Date().timeIntervalSince1970 * 1000, publicJwk: keys.publicJwk, keyHandle: keys.handle, sealedShare: sealed
|
|
642
|
+
))
|
|
643
|
+
stored = true
|
|
644
|
+
return res.shareVersion
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/// Revokes a device with a request signed by this device's key.
|
|
648
|
+
func revokeDevice(_ session: String, deviceId: String) async throws {
|
|
649
|
+
guard !deviceId.isEmpty else { throw WalletError(.invalidRequest, "deviceId required") }
|
|
650
|
+
let (record, _) = try await requireLocal(session)
|
|
651
|
+
try await ctx.api.revokeDevice(session, deviceId: deviceId, proof: try await deviceProof(session, record))
|
|
652
|
+
if record.deviceId == deviceId { try ctx.store.delete(try recordKey(session)) }
|
|
653
|
+
}
|
|
654
|
+
}
|