Skip to content

Commit c3ca935

Browse files
authored
Fix mnemonic checksum generation for cross-SDK compatibility (#2)
The mnemonic encoding/decoding was using big-endian bit packing instead of the little-endian bit packing required by the Algorand SDK spec. Changes: - Rewrite encode() to use little-endian bit packing via toElevenBit() - Rewrite decode() to use little-endian bit unpacking via fromElevenBit() - Add cross-SDK compatibility tests with verified test vectors The fix ensures mnemonics generated by swift-algorand are now accepted by py-algorand-sdk and other official Algorand SDKs.
1 parent 77375c5 commit c3ca935

File tree

2 files changed

+110
-48
lines changed

2 files changed

+110
-48
lines changed

Sources/Algorand/Mnemonic.swift

Lines changed: 75 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -25,36 +25,48 @@ public enum Mnemonic {
2525
throw AlgorandError.encodingError("Key data must be 32 bytes")
2626
}
2727

28-
var words: [String] = []
2928
let wordlist = BIP39Wordlist.english
3029

31-
// Convert key data to bits
32-
var bits = ""
33-
for byte in keyData {
34-
bits += String(byte, radix: 2).leftPadding(toLength: 8, withPad: "0")
35-
}
30+
// Convert key data to 11-bit words using little-endian bit packing
31+
// This matches the Algorand SDK implementation
32+
let keyWords = toElevenBit(Array(keyData))
33+
34+
// Compute checksum: first 11 bits of SHA512/256 hash (little-endian)
35+
let checksumHash = SHA512_256.hash(data: keyData)
36+
let checksumWords = toElevenBit(Array(checksumHash.prefix(2)))
37+
let checksumWord = checksumWords[0]
38+
39+
// Build the 25-word mnemonic: 24 key words + 1 checksum word
40+
var words = keyWords.map { wordlist[$0] }
41+
words.append(wordlist[checksumWord])
42+
43+
return words.joined(separator: " ")
44+
}
3645

37-
// Algorand uses first 8 bits of SHA512/256 as checksum (not SHA256!)
38-
let checksum = SHA512_256.hash(data: keyData)
39-
let checksumByte = Array(checksum)[0]
40-
bits += String(checksumByte, radix: 2).leftPadding(toLength: 8, withPad: "0")
41-
42-
// Now we have 264 bits (256 + 8), which gives us 24 words
43-
// We need exactly 25 words, so the last word encodes the remaining bits (0-padded)
44-
// 264 bits / 11 = 24 words, with 0 bits remaining
45-
// To get 25 words: 25 * 11 = 275 bits needed
46-
// Pad with zeros to get 275 bits
47-
bits += String(repeating: "0", count: 275 - bits.count)
48-
49-
// Convert to 25 words (11 bits each)
50-
for i in stride(from: 0, to: 275, by: 11) {
51-
let chunk = bits[bits.index(bits.startIndex, offsetBy: i)..<bits.index(bits.startIndex, offsetBy: i + 11)]
52-
if let index = Int(chunk, radix: 2) {
53-
words.append(wordlist[index])
46+
/// Converts bytes to 11-bit numbers using little-endian bit packing
47+
/// This matches the Algorand SDK's _to_11_bit function
48+
private static func toElevenBit(_ data: [UInt8]) -> [Int] {
49+
var buffer: UInt32 = 0
50+
var numBits = 0
51+
var output: [Int] = []
52+
53+
for byte in data {
54+
buffer |= UInt32(byte) << numBits
55+
numBits += 8
56+
57+
if numBits >= 11 {
58+
output.append(Int(buffer & 0x7FF))
59+
buffer >>= 11
60+
numBits -= 11
5461
}
5562
}
5663

57-
return words.joined(separator: " ")
64+
// Handle remaining bits
65+
if numBits > 0 {
66+
output.append(Int(buffer & 0x7FF))
67+
}
68+
69+
return output
5870
}
5971

6072
/// Decodes a 25-word mnemonic into key data
@@ -68,42 +80,59 @@ public enum Mnemonic {
6880
}
6981

7082
let wordlist = BIP39Wordlist.english
71-
var bits = ""
7283

84+
// Convert words to 11-bit indices
85+
var indices: [Int] = []
7386
for word in words {
7487
guard let index = wordlist.firstIndex(of: word.lowercased()) else {
7588
throw AlgorandError.invalidMnemonic("Invalid word in mnemonic: \(word)")
7689
}
77-
bits += String(index, radix: 2).leftPadding(toLength: 11, withPad: "0")
90+
indices.append(index)
7891
}
7992

80-
// Extract key data (first 256 bits)
81-
let keyBits = bits.prefix(256)
82-
var keyData = Data()
83-
for i in stride(from: 0, to: 256, by: 8) {
84-
let byte = keyBits[keyBits.index(keyBits.startIndex, offsetBy: i)..<keyBits.index(keyBits.startIndex, offsetBy: i + 8)]
85-
if let byteValue = UInt8(byte, radix: 2) {
86-
keyData.append(byteValue)
87-
}
88-
}
93+
// First 24 words encode the key, last word is checksum
94+
let keyIndices = Array(indices.prefix(24))
95+
let checksumIndex = indices[24]
96+
97+
// Convert 11-bit indices back to bytes using little-endian unpacking
98+
let keyData = fromElevenBit(keyIndices, byteCount: 32)
8999

90-
// Verify checksum (8 bits at position 256-263)
91-
let checksumBits = String(bits[bits.index(bits.startIndex, offsetBy: 256)..<bits.index(bits.startIndex, offsetBy: 264)])
92-
let computedChecksum = SHA512_256.hash(data: keyData)
93-
let computedChecksumByte = Array(computedChecksum)[0]
94-
let computedChecksumBits = String(computedChecksumByte, radix: 2).leftPadding(toLength: 8, withPad: "0")
100+
// Verify checksum
101+
let checksumHash = SHA512_256.hash(data: keyData)
102+
let expectedChecksumWords = toElevenBit(Array(checksumHash.prefix(2)))
103+
let expectedChecksum = expectedChecksumWords[0]
95104

96-
guard checksumBits == computedChecksumBits else {
105+
guard checksumIndex == expectedChecksum else {
97106
throw AlgorandError.invalidMnemonic("Invalid checksum")
98107
}
99108

100-
// Verify padding bits (264-274) are all zeros
101-
let paddingBits = String(bits[bits.index(bits.startIndex, offsetBy: 264)..<bits.index(bits.startIndex, offsetBy: 275)])
102-
guard paddingBits == String(repeating: "0", count: 11) else {
103-
throw AlgorandError.invalidMnemonic("Invalid padding")
109+
return keyData
110+
}
111+
112+
/// Converts 11-bit numbers back to bytes using little-endian bit unpacking
113+
/// This is the inverse of toElevenBit
114+
private static func fromElevenBit(_ indices: [Int], byteCount: Int) -> Data {
115+
var buffer: UInt32 = 0
116+
var numBits = 0
117+
var output: [UInt8] = []
118+
119+
for index in indices {
120+
buffer |= UInt32(index) << numBits
121+
numBits += 11
122+
123+
while numBits >= 8 && output.count < byteCount {
124+
output.append(UInt8(buffer & 0xFF))
125+
buffer >>= 8
126+
numBits -= 8
127+
}
104128
}
105129

106-
return keyData
130+
// Pad with zeros if needed (shouldn't be necessary for valid input)
131+
while output.count < byteCount {
132+
output.append(0)
133+
}
134+
135+
return Data(output)
107136
}
108137

109138
/// Validates a mnemonic

Tests/AlgorandTests/MnemonicTests.swift

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,44 @@ final class MnemonicTests: XCTestCase {
3535
let validMnemonic = try Mnemonic.generate()
3636
XCTAssertTrue(Mnemonic.isValid(validMnemonic))
3737

38-
// Test with tampered padding (word 24 should encode to zeros)
38+
// Test with tampered padding (last word should encode to zeros in padding bits)
3939
var words = validMnemonic.components(separatedBy: " ")
40-
words[24] = "ability" // "ability" = index 1, encodes to non-zero
40+
words[24] = "ability" // "ability" = index 1, encodes to non-zero in padding bits
4141
let invalidMnemonic = words.joined(separator: " ")
4242

4343
XCTAssertFalse(Mnemonic.isValid(invalidMnemonic))
4444
}
45+
46+
func testCrossSDKCompatibility() throws {
47+
// Test vector 1: all-zeros 32-byte key
48+
// Verified against py-algorand-sdk
49+
let zerosMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon invest"
50+
let zerosKeyData = Data(repeating: 0, count: 32)
51+
52+
XCTAssertEqual(try Mnemonic.encode(zerosKeyData), zerosMnemonic)
53+
XCTAssertEqual(try Mnemonic.decode(zerosMnemonic), zerosKeyData)
54+
XCTAssertTrue(Mnemonic.isValid(zerosMnemonic))
55+
56+
// Test vector 2: all-42s 32-byte key
57+
// Verified against py-algorand-sdk: mn._from_key(bytes([42] * 32))
58+
let key42sMnemonic = "earn post bench pencil february melody eyebrow clay earn post bench pencil february melody eyebrow clay earn post bench pencil february melody eyebrow ability tired"
59+
let key42sData = Data(repeating: 42, count: 32)
60+
61+
XCTAssertEqual(try Mnemonic.encode(key42sData), key42sMnemonic)
62+
XCTAssertEqual(try Mnemonic.decode(key42sMnemonic), key42sData)
63+
XCTAssertTrue(Mnemonic.isValid(key42sMnemonic))
64+
}
65+
66+
func testMultipleGeneratedMnemonicsAreValid() throws {
67+
// Generate multiple mnemonics and verify they all pass validation
68+
for _ in 0..<10 {
69+
let mnemonic = try Mnemonic.generate()
70+
XCTAssertTrue(Mnemonic.isValid(mnemonic), "Generated mnemonic should be valid")
71+
72+
// Verify round-trip
73+
let keyData = try Mnemonic.decode(mnemonic)
74+
let reencoded = try Mnemonic.encode(keyData)
75+
XCTAssertEqual(mnemonic, reencoded, "Round-trip should produce identical mnemonic")
76+
}
77+
}
4578
}

0 commit comments

Comments
 (0)