@@ -795,37 +795,117 @@ extension KeychainManager {
795795 /// `identity_privkey.<walletId>.<path>` account scheme).
796796 ///
797797 /// 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.
798+ /// guarded sweep of the legacy (`identity_privkey.<path>` —
799+ /// no walletId) account is also included so callers don't end
800+ /// up with a half-migrated state where the new-format row is
801+ /// gone but a legacy row at the same path lingers.
802+ ///
803+ /// SAFETY: the legacy account format collided across wallets
804+ /// (the very bug we are fixing by namespacing the new path),
805+ /// so deleting a `identity_privkey.<path>` row unconditionally
806+ /// could wipe a DIFFERENT wallet's secret if the two wallets
807+ /// happened to derive an identity at the same DIP-9 path. The
808+ /// sweep therefore looks up the legacy row, decodes its
809+ /// metadata blob, and only deletes when
810+ /// `metadata.walletId == walletIdHex`. Pathological in
811+ /// practice but cheap to defend against.
803812 @discardableResult
804813 public nonisolated func deleteIdentityPrivateKey(
805814 walletId: Data ,
806815 derivationPath: String
807816 ) -> Bool {
808817 let walletIdHex = walletId. toHexString ( )
809818 let newAccount = " identity_privkey. \( walletIdHex) . \( derivationPath) "
810- let legacyAccount = " identity_privkey. \( derivationPath) "
811- var ok = true
812- for account in [ newAccount, legacyAccount] {
813- var query : [ String : Any ] = [
814- kSecClass as String : kSecClassGenericPassword,
815- kSecAttrService as String : serviceName,
816- kSecAttrAccount as String : account,
817- ]
818- if let accessGroup = accessGroup {
819- query [ kSecAttrAccessGroup as String ] = accessGroup
820- }
821- let status = SecItemDelete ( query as CFDictionary )
822- if status != errSecSuccess && status != errSecItemNotFound {
823- ok = false
824- }
819+ var newQuery : [ String : Any ] = [
820+ kSecClass as String : kSecClassGenericPassword,
821+ kSecAttrService as String : serviceName,
822+ kSecAttrAccount as String : newAccount,
823+ ]
824+ if let accessGroup = accessGroup {
825+ newQuery [ kSecAttrAccessGroup as String ] = accessGroup
826+ }
827+ let newStatus = SecItemDelete ( newQuery as CFDictionary )
828+ var ok = newStatus == errSecSuccess || newStatus == errSecItemNotFound
829+
830+ if !deleteLegacyKeychainEntryIfOwnedByWallet(
831+ walletIdHex: walletIdHex,
832+ derivationPath: derivationPath
833+ ) {
834+ // Legacy sweep had a non-recoverable error (something
835+ // other than "not found" / "different wallet's row").
836+ // The new-format delete already happened, so the
837+ // caller's primary intent landed — surface the legacy
838+ // failure via the return value so anyone treating it
839+ // as a strict success can react.
840+ ok = false
825841 }
826842 return ok
827843 }
828844
845+ /// Best-effort delete of the legacy (no-walletId) keychain
846+ /// entry for `derivationPath`, gated on its decoded
847+ /// `IdentityPrivateKeyMetadata.walletId` matching `walletIdHex`.
848+ /// No-op when the row doesn't exist or belongs to a different
849+ /// wallet. Returns `false` only on a genuine Keychain error
850+ /// (NOT for "not found" or "different wallet's data") so the
851+ /// caller can distinguish "nothing to do" from "tried + failed".
852+ ///
853+ /// Shared by both `deleteIdentityPrivateKey` (per-key delete)
854+ /// and `WalletKeyHealthSheet.cleanupLegacyKeychainEntry`
855+ /// (post-rederive cleanup) so both call sites apply the same
856+ /// safety guard.
857+ public nonisolated func deleteLegacyKeychainEntryIfOwnedByWallet(
858+ walletIdHex: String ,
859+ derivationPath: String
860+ ) -> Bool {
861+ let legacyAccount = " identity_privkey. \( derivationPath) "
862+ var lookupQuery : [ String : Any ] = [
863+ kSecClass as String : kSecClassGenericPassword,
864+ kSecAttrService as String : serviceName,
865+ kSecAttrAccount as String : legacyAccount,
866+ kSecReturnAttributes as String : true ,
867+ kSecMatchLimit as String : kSecMatchLimitOne,
868+ ]
869+ if let accessGroup = accessGroup {
870+ lookupQuery [ kSecAttrAccessGroup as String ] = accessGroup
871+ }
872+
873+ var result : AnyObject ?
874+ let lookupStatus = SecItemCopyMatching ( lookupQuery as CFDictionary , & result)
875+ if lookupStatus == errSecItemNotFound {
876+ return true
877+ }
878+ guard lookupStatus == errSecSuccess, let attrs = result as? [ String : Any ] else {
879+ return false
880+ }
881+ guard let metadataData = attrs [ kSecAttrGeneric as String ] as? Data ,
882+ let metadata = try ? JSONDecoder ( ) . decode (
883+ IdentityPrivateKeyMetadata . self,
884+ from: metadataData
885+ )
886+ else {
887+ // Row exists but metadata is malformed / missing. Be
888+ // conservative: refuse to delete data we can't prove
889+ // we own. Treat as success — we successfully decided
890+ // not to delete.
891+ return true
892+ }
893+ guard metadata. walletId. caseInsensitiveCompare ( walletIdHex) == . orderedSame else {
894+ return true
895+ }
896+
897+ var deleteQuery : [ String : Any ] = [
898+ kSecClass as String : kSecClassGenericPassword,
899+ kSecAttrService as String : serviceName,
900+ kSecAttrAccount as String : legacyAccount,
901+ ]
902+ if let accessGroup = accessGroup {
903+ deleteQuery [ kSecAttrAccessGroup as String ] = accessGroup
904+ }
905+ let deleteStatus = SecItemDelete ( deleteQuery as CFDictionary )
906+ return deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound
907+ }
908+
829909 /// Delete every `identity_privkey.*` keychain row whose
830910 /// `IdentityPrivateKeyMetadata.identityId` matches
831911 /// `identityIdBase58` — the base58 identity id Swift uses
0 commit comments