diff --git a/Sources/KukaiCryptoSwift/Mnemonic/EntropyGenerator.swift b/Sources/KukaiCryptoSwift/Mnemonic/EntropyGenerator.swift index fb1a09b..b377077 100644 --- a/Sources/KukaiCryptoSwift/Mnemonic/EntropyGenerator.swift +++ b/Sources/KukaiCryptoSwift/Mnemonic/EntropyGenerator.swift @@ -37,6 +37,12 @@ extension Int: EntropyGenerator { public static var strongest: Int { 256 } } +extension Data: EntropyGenerator { + public func entropy() -> Result { + return Result.success(self) + } +} + extension EntropyGenerator where Self: StringProtocol { /** * Interprets `self` as a string of pre-computed _entropy_, at least if its diff --git a/Sources/KukaiCryptoSwift/Mnemonic/Mnemonic.swift b/Sources/KukaiCryptoSwift/Mnemonic/Mnemonic.swift index 45606d1..79b59e9 100644 --- a/Sources/KukaiCryptoSwift/Mnemonic/Mnemonic.swift +++ b/Sources/KukaiCryptoSwift/Mnemonic/Mnemonic.swift @@ -9,6 +9,9 @@ public enum MnemonicError: Swift.Error { case seedDerivationFailed case seedPhraseInvalid(String) case error(Swift.Error) + case invalidWordCount + case invalidWordToShift + case invalidMnemonic } /** @@ -181,4 +184,123 @@ public struct Mnemonic: Equatable, Codable { return Mnemonic.isValidChecksum(phrase: words, wordlist: vocabulary) } + + /** + Modifed from: https://github.com/pengpengliu/BIP39/blob/master/Sources/BIP39/Mnemonic.swift + Convert the current Mnemonic back into entropy + */ + public func toEntropy(ignoreChecksum: Bool, wordlist: WordList = WordList.english) throws -> [UInt8] { + let wordListWords = wordlist.words + + let bits = try words.map { (word) -> String in + guard let index = wordListWords.firstIndex(of: word) else { + throw MnemonicError.invalidMnemonic + } + + var str = String(index, radix:2) + while str.count < 11 { + str = "0" + str + } + return str + }.joined(separator: "") + + let dividerIndex = Int(Double(bits.count / 33).rounded(.down) * 32) + let entropyBits = String(bits.prefix(dividerIndex)) + let checksumBits = String(bits.suffix(bits.count - dividerIndex)) + + let regex = try! NSRegularExpression(pattern: "[01]{1,8}", options: .caseInsensitive) + let entropyBytes = regex.matches(in: entropyBits, options: [], range: NSRange(location: 0, length: entropyBits.count)).map { + UInt8(strtoul(String(entropyBits[Range($0.range, in: entropyBits)!]), nil, 2)) + } + + if !ignoreChecksum && (checksumBits != Mnemonic.deriveChecksumBits(entropyBytes)) { + throw MnemonicError.invalidMnemonic + } + + return entropyBytes + } + + /** + Take a `PrivateKey` from a TorusWallet and generate a custom "shifted checksum" mnemonic, so that we can recover wallets that previously had no seed words + */ + public static func shiftedMnemonic(fromSpskPrivateKey pk: PrivateKey) -> Mnemonic? { + guard let entropy = Base58Check.decode(string: pk.base58CheckRepresentation, prefix: Prefix.Keys.Secp256k1.secret) else { + return nil + } + + let data = Data(entropy) + guard let mnemonic = try? Mnemonic(entropy: data) else { + return nil + } + + return try? shiftChecksum(mnemonic: mnemonic) + } + + /** + Shift the checksum of of a `Mnemonic` so that it won't be accepted by tradtional improts + */ + public static func shiftChecksum(mnemonic: Mnemonic, wordList: WordList = WordList.english) throws -> Mnemonic { + var mutableMnemonic = mnemonic + guard mutableMnemonic.words.count == 24, + let lastWord = mutableMnemonic.words.last, + let shiftedWord = try? Mnemonic.getShiftedWord(word: lastWord, wordList: wordList) else { + throw MnemonicError.invalidWordCount + } + + var isValidMnemonic = (mutableMnemonic.isValid() ? 1 : 0) + mutableMnemonic.phrase = mutableMnemonic.phrase.replacingOccurrences(of: lastWord, with: shiftedWord) + isValidMnemonic += (mutableMnemonic.isValid() ? 1 : 0) + + if isValidMnemonic != 1 { + throw MnemonicError.invalidMnemonic + } else { + return mutableMnemonic + } + } + + /** + Return a shifted word to replace the last word in a mnemonic + */ + public static func getShiftedWord(word: String, wordList: WordList = WordList.english) throws -> String { + let words = wordList.words + guard let wordIndex = words.firstIndex(of: word) else { + throw MnemonicError.invalidWordToShift + } + + let checksumByte = wordIndex % 256 + let newIndex = wordIndex - checksumByte + ((checksumByte + 128) % 256) + + if words.count > newIndex { + return words[newIndex] + } else { + throw MnemonicError.invalidWordToShift + } + } + + /** + Convert a mnemonic to a Base58 encoded private key string. Helpful when determining if a shifted mnemonic is valid + */ + public static func mnemonicToSpsk(mnemonic: Mnemonic, wordList: WordList = WordList.english) -> String? { + guard let bytes = try? mnemonic.toEntropy(ignoreChecksum: true, wordlist: wordList) else { + return nil + } + + return Base58Check.encode(message: bytes, prefix: Prefix.Keys.Secp256k1.secret) + } + + /** + Convert a shifted Mnemoinc back to normal + */ + public static func shiftedMnemonicToMnemonic(mnemonic: Mnemonic) -> Mnemonic? { + return try? shiftChecksum(mnemonic: mnemonic) + } + + /** + Check if a supplied Spsk string is valid + */ + public static func validSpsk(_ sk: String) -> Bool { + let canDecode = Base58Check.decode(string: sk, prefix: Prefix.Keys.Secp256k1.secret) + + return sk.count == 54 && canDecode != nil + } } diff --git a/Tests/KukaiCryptoSwiftTests/MnemonicTests.swift b/Tests/KukaiCryptoSwiftTests/MnemonicTests.swift index 47611a6..2e45307 100644 --- a/Tests/KukaiCryptoSwiftTests/MnemonicTests.swift +++ b/Tests/KukaiCryptoSwiftTests/MnemonicTests.swift @@ -82,4 +82,44 @@ final class MnemonicTests: XCTestCase { let mnemonic8 = try Mnemonic(seedPhrase: "Kit trigger pledge excess payment sentence dutch mandate start sense seed venture") XCTAssert(mnemonic8.isValid() == false) } + + func testShifting() throws { + let privateKeyBytes: [UInt8] = [125, 133, 194, 84, 250, 98, 79, 41, 174, 84, 233, 129, 41, 85, 148, 33, 44, 186, 87, 103, 235, 213, 247, 99, 133, 29, 151, 197, 91, 106, 136, 214] + let privateKey = PrivateKey(privateKeyBytes, signingCurve: .secp256k1) + + let expectedShiftedWords = "laugh come news visit ceiling network rich outdoor license enjoy govern drastic slight close panic kingdom wash bring electric convince fiber relief cash siren" + let expectedNormalWords = "laugh come news visit ceiling network rich outdoor license enjoy govern drastic slight close panic kingdom wash bring electric convince fiber relief cash sunny" + let expectedTz2Address = "tz2HpbGQcmU3UyusJ78Sbqeg9fYteamSMDGo" + + + // Test shift + guard let shiftedMnemonic = Mnemonic.shiftedMnemonic(fromSpskPrivateKey: privateKey) else { + XCTFail("Couldn't create shifted Mnemonic") + return + } + + let joinedWords = shiftedMnemonic.words.joined(separator: " ") + XCTAssert(joinedWords == expectedShiftedWords, joinedWords) + + // Test unshift + let shiftedSpsk = Mnemonic.mnemonicToSpsk(mnemonic: shiftedMnemonic) + XCTAssert(shiftedSpsk == "spsk2Nqz6AW1zVwLJ3QgcXhzPNdT3mpRskUKA2UXza5kNRd3NLKrMy", shiftedSpsk ?? "-") + XCTAssert(Mnemonic.validSpsk(shiftedSpsk ?? "")) + + guard let normalMnemonic = Mnemonic.shiftedMnemonicToMnemonic(mnemonic: shiftedMnemonic) else { + XCTFail("Couldn't create normal Mnemonic") + return + } + + let normalJoinedWords = normalMnemonic.words.joined(separator: " ") + XCTAssert(normalJoinedWords == expectedNormalWords, normalJoinedWords) + + let normalSpsk = Mnemonic.mnemonicToSpsk(mnemonic: normalMnemonic) + XCTAssert(normalSpsk == "spsk2Nqz6AW1zVwLJ3QgcXhzPNdT3mpRskUKA2UXza5kNRd3NLKrMy", normalSpsk ?? "-") + + let normalSpskBytes = Base58Check.decode(string: normalSpsk ?? "", prefix: Prefix.Keys.Secp256k1.secret) + let normalPrivateKey = PrivateKey(normalSpskBytes ?? [], signingCurve: .secp256k1) + let normalPublicKey = KeyPair.secp256k1PublicKey(fromPrivateKeyBytes: normalPrivateKey.bytes) + XCTAssert(normalPublicKey?.publicKeyHash == expectedTz2Address, normalPublicKey?.publicKeyHash ?? "-") + } }