Skip to content

Commit ea4c16a

Browse files
fix(swift-sdk): address PR #3765 review feedback
- WalletKeyHealthSheet: branch HASH160 keys against pubkey-hash hex instead of comparing the 33-byte derived pubkey to the 20-byte stored hash; report unsupported key types as orphan with a clear reason. Rederive returns per-key failures so the sheet can show WHICH key is stuck. deleteOrphan also wipes the identity's Keychain entries. - KeychainManager: deleteIdentityPrivateKey now takes walletId (symmetric with store) and also sweeps the legacy account. Added deleteAllIdentityPrivateKeys(forIdentityIdBase58:). - AddIdentityKeyView: system contract entries always win over user-saved rows that share an ID, so DashPay's bounded document- type metadata isn't masked by a parallel saved-contract entry. - CreateIdentityView: shouldRegisterDashPayKeys computed gate mirrors the UI section visibility; submit() and the funding-min calc both route through it. KeyValidation.validatePrivateKeyForPublicKey added to makeDashpayKeyPair before persisting. - PlatformWalletManager.deleteWallet: keychain cleanup moved BEFORE deleteWalletData so a partial-failure retry can still find the identityIds to purge. - StorageRecordDetailViews: AccountStorageDetailView now uses the walletLabel(record.wallet) helper. - SDK.swift: platform_version=11 pin has an explicit TODO + trigger describing when to bump it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2bf062c commit ea4c16a

7 files changed

Lines changed: 273 additions & 49 deletions

File tree

‎packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,22 @@ public class PlatformWalletManager: ObservableObject {
544544

545545
letidentityIds=try persistenceHandler.identityIdsForWallet(walletId: walletId)
546546

547+
// Wipe Keychain BEFORE the SwiftData identity deletion runs.
548+
// Order matters for retry-safety: if `deleteWalletData`
549+
// commits identity rows and then throws partway, a retry
550+
// would see `identityIdsForWallet == []` and the
551+
// `deleteAllKeychainItems(forIdentityId:)` sweep below
552+
// could no longer find the keys to purge. Doing the
553+
// keychain side first leaves at worst stale SwiftData
554+
// rows on a retry — repeating the wipe is harmless, and
555+
// every keychain call here is idempotent (no-op on "not
556+
// found"). Mnemonic / metadata stay in `WalletStorage`
557+
// for now so a retry can still derive any missed key.
558+
foridentityIdin identityIds {
559+
tryKeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId)
560+
}
561+
tryKeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId)
562+
547563
try walletId.withUnsafeBytes{ raw in
548564
guardlet base = raw.baseAddress?.assumingMemoryBound(to:FFIByteTuple32.self)else{
549565
throwPlatformWalletError.nullPointer(
@@ -557,11 +573,6 @@ public class PlatformWalletManager: ObservableObject {
557573

558574
try persistenceHandler.deleteWalletData(walletId: walletId)
559575

560-
foridentityIdin identityIds {
561-
tryKeychainManager.shared.deleteAllKeychainItems(forIdentityId: identityId)
562-
}
563-
tryKeychainManager.shared.deleteAllIdentityPrivateKeys(forWalletId: walletId)
564-
565576
letstorage=WalletStorage()
566577
// Delete metadata first so the mnemonic remains available for retry.
567578
try storage.deleteMetadata(for: walletId)

‎packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,14 @@ public final class SDK: @unchecked Sendable {
255255
}else{
256256
switch network {
257257
case.mainnet,.testnet:
258+
// TODO(platform-version-bump): bump mainnet/testnet to 12
259+
// (or whatever PV is current) once drive-abci 3.1+ has
260+
// rolled out on those networks and the new
261+
// `getDocuments` V1 wire format is on by default. The
262+
// trigger is: a HardFork shipping the V1 wire format
263+
// becomes active on mainnet/testnet. Until then, pinning
264+
// 11 keeps the SDK speaking the V0 protocol the active
265+
// tenderdash quorums understand.
258266
resolvedPlatformVersion =11
259267
case.devnet,.regtest:
260268
resolvedPlatformVersion =12

‎packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -789,21 +789,96 @@ extension KeychainManager {
789789
returnfalse
790790
}
791791

792-
/// Delete the identity private-key row for `derivationPath`.
793-
/// Idempotent; returns true on success or "not found".
792+
/// Delete the identity private-key row for the
793+
/// `(walletId, derivationPath)` pair — symmetric with
794+
/// `storeIdentityPrivateKey` (which writes under the
795+
/// `identity_privkey.<walletId>.<path>` account scheme).
796+
///
797+
/// Idempotent; returns true on success or "not found". A
798+
/// best-effort sweep of the legacy
799+
/// (`identity_privkey.<path>` — no walletId) account is
800+
/// included so callers don't end up with a half-migrated state
801+
/// where the new-format row is gone but a legacy row at the
802+
/// same path lingers.
794803
@discardableResult
795-
publicnonisolatedfunc deleteIdentityPrivateKey(derivationPath:String)->Bool{
796-
letaccount="identity_privkey.\(derivationPath)"
804+
publicnonisolatedfunc deleteIdentityPrivateKey(
805+
walletId:Data,
806+
derivationPath:String
807+
)->Bool{
808+
letwalletIdHex= walletId.toHexString()
809+
letnewAccount="identity_privkey.\(walletIdHex).\(derivationPath)"
810+
letlegacyAccount="identity_privkey.\(derivationPath)"
811+
varok=true
812+
foraccountin[newAccount, legacyAccount]{
813+
varquery:[String:Any]=[
814+
kSecClass asString: kSecClassGenericPassword,
815+
kSecAttrService asString: serviceName,
816+
kSecAttrAccount asString: account,
817+
]
818+
iflet accessGroup = accessGroup {
819+
query[kSecAttrAccessGroup asString]= accessGroup
820+
}
821+
letstatus=SecItemDelete(query asCFDictionary)
822+
if status != errSecSuccess && status != errSecItemNotFound {
823+
ok =false
824+
}
825+
}
826+
return ok
827+
}
828+
829+
/// Delete every `identity_privkey.*` keychain row whose
830+
/// `IdentityPrivateKeyMetadata.identityId` matches
831+
/// `identityIdBase58` — the base58 identity id Swift uses
832+
/// throughout the persistence layer. Used by the key-health
833+
/// sheet's "Delete orphan identity" action so cascading the
834+
/// SwiftData row doesn't leave its keys' private bytes behind
835+
/// in Keychain.
836+
///
837+
/// Scans the metadata blob (`kSecAttrGeneric`) on every
838+
/// matching keychain item — handles both new-format
839+
/// (`identity_privkey.<walletId>.<path>`) and legacy-format
840+
/// (`identity_privkey.<path>`) accounts uniformly. Idempotent;
841+
/// no-op when nothing matches.
842+
publicnonisolatedfunc deleteAllIdentityPrivateKeys(forIdentityIdBase58 identityIdBase58:String)throws{
797843
varquery:[String:Any]=[
798844
kSecClass asString: kSecClassGenericPassword,
799845
kSecAttrService asString: serviceName,
800-
kSecAttrAccount asString: account,
846+
kSecMatchLimit asString: kSecMatchLimitAll,
847+
kSecReturnAttributes asString:true,
801848
]
802849
iflet accessGroup = accessGroup {
803850
query[kSecAttrAccessGroup asString]= accessGroup
804851
}
805-
letstatus=SecItemDelete(query asCFDictionary)
806-
return status == errSecSuccess || status == errSecItemNotFound
852+
853+
varresult:AnyObject?
854+
letstatus=SecItemCopyMatching(query asCFDictionary,&result)
855+
if status == errSecItemNotFound {
856+
return
857+
}
858+
guard status == errSecSuccess,let items = result as?[[String:Any]]else{
859+
throwKeychainError.retrieveFailed(status)
860+
}
861+
862+
letdecoder=JSONDecoder()
863+
foritemin items {
864+
guardlet account =item[kSecAttrAccount asString]as?String,
865+
account.hasPrefix("identity_privkey.")
866+
else{
867+
continue
868+
}
869+
guardlet metadataData =item[kSecAttrGeneric asString]as?Data,
870+
let metadata =try? decoder.decode(
871+
IdentityPrivateKeyMetadata.self,
872+
from: metadataData
873+
)
874+
else{
875+
continue
876+
}
877+
guard metadata.identityId == identityIdBase58 else{
878+
continue
879+
}
880+
trydeleteGenericPassword(account: account)
881+
}
807882
}
808883

809884
/// Delete every `identity_privkey.<derivationPath>` keychain row whose

‎packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletKeyHealthSheet.swift‎

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,35 @@ enum WalletKeyHealthChecker {
189189
keyId: kid,
190190
network: network
191191
)
192-
derivedHex = preview.publicKeyHex
193192

194-
if preview.publicKeyData == row.publicKeyData {
193+
// `row.publicKeyData` stores whatever shape was
194+
// registered on Platform — 33-byte compressed
195+
// pubkey for `.ecdsaSecp256k1`, 20-byte HASH160
196+
// for `.ecdsaHash160`. The Rust-derived preview
197+
// is always the raw 33-byte pubkey; hash it
198+
// ourselves before comparing for HASH160 rows.
199+
// (Other variants — BLS, BIP13 script-hash,
200+
// EdDSA — aren't produced by this preview path
201+
// today; treat them as `notSupported` so the
202+
// diagnostic surfaces them instead of silently
203+
// misclassifying them as orphans.)
204+
letderivedComparableHex:String?
205+
switch row.keyTypeEnum ??.ecdsaSecp256k1 {
206+
case.ecdsaSecp256k1:
207+
derivedComparableHex = preview.publicKeyHex
208+
derivedHex = preview.publicKeyHex
209+
case.ecdsaHash160:
210+
lethashHex=SwiftDashSDK.KeychainManager
211+
.computePublicKeyHashHex(preview.publicKeyData)
212+
derivedComparableHex = hashHex.isEmpty ?nil: hashHex
213+
derivedHex = hashHex
214+
case.bls12_381,.bip13ScriptHash,.eddsa25519Hash160:
215+
derivedComparableHex =nil
216+
derivedHex = preview.publicKeyHex
217+
}
218+
219+
iflet derivedComparableHex,
220+
derivedComparableHex.caseInsensitiveCompare(storedHex)==.orderedSame {
195221
// pubkey matches the wallet's mnemonic →
196222
// look up the keychain bytes at the expected
197223
// walletId-namespaced account.
@@ -224,9 +250,20 @@ enum WalletKeyHealthChecker {
224250
reason:"No new-format Keychain entry (legacy-only entries don't count)"
225251
)
226252
}
253+
}elseif derivedComparableHex ==nil{
254+
// Key type isn't one the diagnostic knows
255+
// how to compare against a Rust-derived
256+
// pubkey today (BLS / BIP13 / EdDSA). Report
257+
// it as orphan with a clear reason so the
258+
// user knows we can't verify it, rather than
259+
// silently passing.
260+
letlabel= row.keyTypeEnum?.name ??"type \(row.keyType)"
261+
status =.orphan(
262+
reason:"Key type \(label) isn't supported by the diagnostic — can't verify against derived pubkey"
263+
)
227264
}else{
228265
status =.orphan(
229-
reason:"Stored pubkey \(storedHex.prefix(12))… doesn't match wallet's derivation \(derivedHex.prefix(12))"
266+
reason:"Stored \(storedHex.prefix(12))… doesn't match wallet's derivation \(derivedHex.prefix(12))"
230267
)
231268
}
232269
}catch{
@@ -262,27 +299,53 @@ enum WalletKeyHealthChecker {
262299
return reports
263300
}
264301

302+
/// Per-key result of a rederive pass. `success` is the count of
303+
/// keys whose Keychain entry was rewritten; `failures` lists each
304+
/// key the pass tried and couldn't fix, with a reason — useful so
305+
/// the sheet can show the user *which* key is stuck (not just
306+
/// "Re-derived 0 keys").
307+
structRederiveOutcome{
308+
letsuccess:Int
309+
/// `(keyId, reason)` for each `.needsRederive` key the loop
310+
/// touched but did not fix.
311+
letfailures:[(UInt32,String)]
312+
}
313+
265314
/// Re-derive every key in `report` whose status is
266315
/// `.needsRederive`, write fresh Keychain entries (at the new
267316
/// walletId-namespaced account), and update each
268317
/// `PersistentPublicKey.privateKeyKeychainIdentifier` to point
269-
/// at the new account. Returns the number of keys fixed.
318+
/// at the new account.
319+
///
320+
/// Returns a `RederiveOutcome` with both the count fixed and a
321+
/// per-key list of failures. Individual key failures are
322+
/// collected rather than thrown so one bad key doesn't block
323+
/// repair of the rest of the identity's keys; throws only when
324+
/// the whole batch is unrecoverable (e.g. the SwiftData save at
325+
/// the end fails).
270326
@MainActor
271327
staticfunc rederive(
272328
report:WalletIdentityKeyHealthReport,
273329
wallet:ManagedPlatformWallet,
274330
walletId:Data,
275331
network:Network,
276332
modelContext:ModelContext
277-
)throws->Int{
333+
)throws->RederiveOutcome{
278334
varfixed=0
335+
varfailures:[(UInt32,String)]=[]
279336
forkeyin report.keys {
280337
guard case .needsRederive = key.status else{continue}
281-
letpreview=try wallet.deriveIdentityAuthKeyAtSlot(
282-
identityIndex: report.identityIndex,
283-
keyId: key.keyId,
284-
network: network
285-
)
338+
letpreview:ManagedPlatformWallet.IdentityRegistrationKeyPreview
339+
do{
340+
preview =try wallet.deriveIdentityAuthKeyAtSlot(
341+
identityIndex: report.identityIndex,
342+
keyId: key.keyId,
343+
network: network
344+
)
345+
}catch{
346+
failures.append((key.keyId,"derivation failed: \(error.localizedDescription)"))
347+
continue
348+
}
286349
letpubkeyHashHex=SwiftDashSDK.KeychainManager.computePublicKeyHashHex(preview.publicKeyData)
287350
letmetadata=IdentityPrivateKeyMetadata(
288351
identityId: report.identityIdBase58,
@@ -302,6 +365,7 @@ enum WalletKeyHealthChecker {
302365
derivationPath: preview.derivationPath,
303366
metadata: metadata
304367
)else{
368+
failures.append((key.keyId,"Keychain write at \(preview.derivationPath) returned nil"))
305369
continue
306370
}
307371
key.row.privateKeyKeychainIdentifier = pkid
@@ -321,22 +385,36 @@ enum WalletKeyHealthChecker {
321385
if fixed >0{
322386
try modelContext.save()
323387
}
324-
return fixed
388+
returnRederiveOutcome(success:fixed, failures: failures)
325389
}
326390

327-
/// Cascade-delete an orphan identity from SwiftData. Safe to
328-
/// call now that the relationship inverses
329-
/// (`PersistentPublicKey.identity`, `PersistentDPNSName.identity`,
330-
/// `PersistentDashpayProfile.identity`, `PersistentDashpayContactRequest.owner`)
331-
/// are all Optional — see the doc comments on those models for
332-
/// the SwiftData cascade-on-non-optional crash this avoids.
391+
/// Cascade-delete an orphan identity from SwiftData AND wipe its
392+
/// associated Keychain entries. Order matters: clear the
393+
/// Keychain side first (purely additive to the SwiftData state),
394+
/// then drop the row. If Keychain wipe fails we still try the
395+
/// SwiftData delete so the user can finish the operation; the
396+
/// keychain error is surfaced to the caller.
333397
@MainActor
334398
staticfunc deleteOrphan(
335399
identity:PersistentIdentity,
336400
modelContext:ModelContext
337401
)throws{
402+
// Snapshot the base58 id BEFORE the delete — once the row
403+
// is removed its computed accessor is invalid.
404+
letidentityIdBase58= identity.identityIdBase58
405+
varkeychainError:Error?
406+
do{
407+
tryKeychainManager.shared.deleteAllIdentityPrivateKeys(
408+
forIdentityIdBase58: identityIdBase58
409+
)
410+
}catch{
411+
keychainError = error
412+
}
338413
modelContext.delete(identity)
339414
try modelContext.save()
415+
iflet keychainError {
416+
throw keychainError
417+
}
340418
}
341419
}
342420

@@ -550,15 +628,23 @@ struct WalletKeyHealthSheet: View {
550628
privatefunc rederive(_ report:WalletIdentityKeyHealthReport){
551629
Task{@MainActorin
552630
do{
553-
letfixed=tryWalletKeyHealthChecker.rederive(
631+
letoutcome=tryWalletKeyHealthChecker.rederive(
554632
report: report,
555633
wallet: wallet,
556634
walletId: walletId,
557635
network: network,
558636
modelContext: modelContext
559637
)
560-
actionMessage ="Re-derived \(fixed) key\(fixed ==1?"":"s") for identity \(report.identityIdBase58.prefix(12))"
561-
errorMessage =nil
638+
letfixed= outcome.success
639+
varmsg="Re-derived \(fixed) key\(fixed ==1?"":"s") for identity \(report.identityIdBase58.prefix(12))"
640+
if !outcome.failures.isEmpty {
641+
letdetail= outcome.failures
642+
.map{"kid \($0.0): \($0.1)"}
643+
.joined(separator:"; ")
644+
msg +="\(outcome.failures.count) failed: \(detail)"
645+
}
646+
actionMessage = msg
647+
errorMessage = outcome.failures.isEmpty ?nil: msg
562648
// Re-run the check so the report reflects the new
563649
// state (formerly-orange rows should turn green).
564650
awaitrunCheck()

0 commit comments

Comments
 (0)