Skip to content

Commit cd8c84e

Browse files
joe-pCopilot
andauthored
feat: algo25 module (#335)
Co-authored-by: Copilot <[email protected]>
1 parent 878644f commit cd8c84e

28 files changed

Lines changed: 3390 additions & 1800 deletions

File tree

.github/workflows/swift_ci.yml

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,43 @@ jobs:
5757
run: cargo pkg ${{ env.CRATE }} swift
5858

5959
# Ideally we'd use a matrix for the platforms, but due to the limitations of Mac runners on GitHub it's probably better to just have a single job with multiple steps
60+
- name: Detect iOS simulator
61+
id: ios_sim
62+
run: |
63+
DEST_ID="$(python3 - <<'PY'
64+
import json, subprocess, re
65+
66+
data = json.loads(subprocess.check_output(
67+
["xcrun", "simctl", "list", "devices", "available", "-j"],
68+
text=True
69+
))
70+
71+
def runtime_version(key: str):
72+
# Example key: com.apple.CoreSimulator.SimRuntime.iOS-18-2
73+
m = re.search(r"iOS-(\d+)-(\d+)", key)
74+
return (int(m.group(1)), int(m.group(2))) if m else (-1, -1)
75+
76+
ios_runtimes = [k for k in data["devices"].keys() if "SimRuntime.iOS-" in k]
77+
ios_runtimes.sort(key=runtime_version, reverse=True)
78+
79+
udid = None
80+
for runtime in ios_runtimes:
81+
devices = data["devices"][runtime]
82+
iphones = [d for d in devices if d.get("isAvailable") and d.get("name", "").startswith("iPhone")]
83+
if iphones:
84+
udid = iphones[0]["udid"]
85+
break
86+
87+
if not udid:
88+
raise SystemExit("No available iPhone simulator found")
89+
90+
print(udid)
91+
PY
92+
)"
93+
echo "destination=id=${DEST_ID}" >> "$GITHUB_OUTPUT"
94+
6095
- name: Test (iOS)
61-
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "platform=iOS Simulator,name=iPhone 17,OS=latest"
96+
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "${{ steps.ios_sim.outputs.destination }}"
6297
- name: Test (macOS)
6398
run: cd packages/swift/${{ env.PACKAGE }} && xcodebuild -scheme ${{ env.PACKAGE }} test -destination "platform=macOS"
6499
- name: Test (Catalyst)
@@ -74,4 +109,3 @@ jobs:
74109
with:
75110
name: ${{ env.PACKAGE }}
76111
path: packages/swift/${{ env.PACKAGE }}.zip
77-

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/algokit_crypto/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ zeroize = { version = "1.8", features = ["derive"] }
1616
# Used random seed generation for ed25519
1717
getrandom = "0.4.1"
1818
async-trait = "0.1.89"
19+
sha2.workspace = true
1920

2021
[dev-dependencies]
2122
tokio = { version = "1.49.0", features = ["macros", "rt"] }

crates/algokit_crypto/src/algo25/english.rs

Lines changed: 213 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
mod english;
2+
3+
use english::ENGLISH;
4+
use sha2::{Digest, Sha512_256};
5+
6+
pub const FAIL_TO_DECODE_MNEMONIC_ERROR_MSG: &str = "failed to decode mnemonic";
7+
pub const NOT_IN_WORDS_LIST_ERROR_MSG: &str =
8+
"the mnemonic contains a word that is not in the wordlist";
9+
10+
const SEED_BYTES_LENGTH: usize = 32;
11+
12+
#[derive(Debug, Clone, PartialEq, Eq)]
13+
pub enum MnemonicError {
14+
InvalidSeedLength { expected: usize, found: usize },
15+
NotInWordsList,
16+
FailedToDecodeMnemonic,
17+
}
18+
19+
impl core::fmt::Display for MnemonicError {
20+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
21+
match self {
22+
MnemonicError::InvalidSeedLength { expected, .. } => {
23+
write!(f, "Seed length must be {expected}")
24+
}
25+
MnemonicError::NotInWordsList => write!(f, "{NOT_IN_WORDS_LIST_ERROR_MSG}"),
26+
MnemonicError::FailedToDecodeMnemonic => {
27+
write!(f, "{FAIL_TO_DECODE_MNEMONIC_ERROR_MSG}")
28+
}
29+
}
30+
}
31+
}
32+
33+
impl std::error::Error for MnemonicError {}
34+
35+
fn to_uint11_array(buffer8: &[u8]) -> Vec<u16> {
36+
let mut buffer11 = Vec::new();
37+
let mut acc: u32 = 0;
38+
let mut acc_bits: u32 = 0;
39+
40+
for octet in buffer8 {
41+
acc |= u32::from(*octet) << acc_bits;
42+
acc_bits += 8;
43+
44+
if acc_bits >= 11 {
45+
buffer11.push((acc & 0x7ff) as u16);
46+
acc >>= 11;
47+
acc_bits -= 11;
48+
}
49+
}
50+
51+
if acc_bits != 0 {
52+
buffer11.push(acc as u16);
53+
}
54+
55+
buffer11
56+
}
57+
58+
fn to_uint8_array(buffer11: &[u16]) -> Vec<u8> {
59+
let mut buffer8 = Vec::new();
60+
let mut acc: u32 = 0;
61+
let mut acc_bits: u32 = 0;
62+
63+
for ui11 in buffer11 {
64+
acc |= u32::from(*ui11) << acc_bits;
65+
acc_bits += 11;
66+
67+
while acc_bits >= 8 {
68+
buffer8.push((acc & 0xff) as u8);
69+
acc >>= 8;
70+
acc_bits -= 8;
71+
}
72+
}
73+
74+
if acc_bits != 0 {
75+
buffer8.push(acc as u8);
76+
}
77+
78+
buffer8
79+
}
80+
81+
fn apply_words(nums: &[u16]) -> Vec<&'static str> {
82+
nums.iter().map(|n| ENGLISH[*n as usize]).collect()
83+
}
84+
85+
fn english_index(word: &str) -> Option<usize> {
86+
ENGLISH.iter().position(|candidate| *candidate == word)
87+
}
88+
89+
fn compute_checksum(seed: &[u8; SEED_BYTES_LENGTH]) -> &'static str {
90+
let mut hasher = Sha512_256::new();
91+
hasher.update(seed);
92+
let hash = hasher.finalize();
93+
94+
let uint11_hash = to_uint11_array(hash.as_ref());
95+
let words = apply_words(&uint11_hash);
96+
words[0]
97+
}
98+
99+
/// Converts a 32-byte key into a 25-word mnemonic including checksum.
100+
pub fn mnemonic_from_seed(seed: &[u8]) -> Result<String, MnemonicError> {
101+
if seed.len() != SEED_BYTES_LENGTH {
102+
return Err(MnemonicError::InvalidSeedLength {
103+
expected: SEED_BYTES_LENGTH,
104+
found: seed.len(),
105+
});
106+
}
107+
108+
let seed: [u8; SEED_BYTES_LENGTH] =
109+
seed.try_into()
110+
.map_err(|_| MnemonicError::InvalidSeedLength {
111+
expected: SEED_BYTES_LENGTH,
112+
found: seed.len(),
113+
})?;
114+
115+
let uint11_array = to_uint11_array(&seed);
116+
let words = apply_words(&uint11_array);
117+
let checksum_word = compute_checksum(&seed);
118+
119+
Ok(format!("{} {checksum_word}", words.join(" ")))
120+
}
121+
122+
/// Converts a mnemonic generated by this library back to the source 32-byte seed.
123+
pub fn seed_from_mnemonic(mnemonic: &str) -> Result<[u8; SEED_BYTES_LENGTH], MnemonicError> {
124+
let words: Vec<&str> = mnemonic.split_whitespace().collect();
125+
126+
// Expect exactly 25 words: 24 data words + 1 checksum word.
127+
if words.len() != 25 {
128+
return Err(MnemonicError::FailedToDecodeMnemonic);
129+
}
130+
131+
let key_words = &words[..24];
132+
133+
for w in key_words {
134+
if english_index(w).is_none() {
135+
return Err(MnemonicError::NotInWordsList);
136+
}
137+
}
138+
139+
let checksum = words[24];
140+
let uint11_array: Vec<u16> = key_words
141+
.iter()
142+
.map(|word| english_index(word).expect("checked above") as u16)
143+
.collect();
144+
145+
let mut uint8_array = to_uint8_array(&uint11_array);
146+
147+
if uint8_array.len() != 33 {
148+
return Err(MnemonicError::FailedToDecodeMnemonic);
149+
}
150+
151+
if uint8_array[uint8_array.len() - 1] != 0x0 {
152+
return Err(MnemonicError::FailedToDecodeMnemonic);
153+
}
154+
155+
uint8_array.pop();
156+
157+
let seed: [u8; SEED_BYTES_LENGTH] = uint8_array
158+
.try_into()
159+
.map_err(|_| MnemonicError::FailedToDecodeMnemonic)?;
160+
161+
if compute_checksum(&seed) == checksum {
162+
return Ok(seed);
163+
}
164+
165+
Err(MnemonicError::FailedToDecodeMnemonic)
166+
}
167+
168+
/// Takes an Algorand secret key and returns its associated mnemonic.
169+
pub fn secret_key_to_mnemonic(sk: &[u8]) -> Result<String, MnemonicError> {
170+
let seed = sk
171+
.get(..SEED_BYTES_LENGTH)
172+
.ok_or(MnemonicError::InvalidSeedLength {
173+
expected: SEED_BYTES_LENGTH,
174+
found: sk.len(),
175+
})?;
176+
mnemonic_from_seed(seed)
177+
}
178+
179+
/// Takes a mnemonic and returns the corresponding master derivation key.
180+
pub fn mnemonic_to_master_derivation_key(
181+
mn: &str,
182+
) -> Result<[u8; SEED_BYTES_LENGTH], MnemonicError> {
183+
seed_from_mnemonic(mn)
184+
}
185+
186+
/// Takes a master derivation key and returns the corresponding mnemonic.
187+
pub fn master_derivation_key_to_mnemonic(mdk: &[u8]) -> Result<String, MnemonicError> {
188+
mnemonic_from_seed(mdk)
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn seed_round_trip() {
197+
let seed = [7u8; SEED_BYTES_LENGTH];
198+
let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode");
199+
let decoded = seed_from_mnemonic(&mnemonic).expect("mnemonic should decode");
200+
assert_eq!(decoded, seed);
201+
}
202+
203+
#[test]
204+
fn rejects_non_wordlist_words() {
205+
let seed = [3u8; SEED_BYTES_LENGTH];
206+
let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode");
207+
let mut words: Vec<&str> = mnemonic.split(' ').collect();
208+
words[0] = "notaword";
209+
let broken = words.join(" ");
210+
211+
let err = seed_from_mnemonic(&broken).expect_err("should fail");
212+
assert_eq!(err, MnemonicError::NotInWordsList);
213+
assert_eq!(err.to_string(), NOT_IN_WORDS_LIST_ERROR_MSG);
214+
}
215+
216+
#[test]
217+
fn rejects_bad_checksum() {
218+
let seed = [11u8; SEED_BYTES_LENGTH];
219+
let mnemonic = mnemonic_from_seed(&seed).expect("mnemonic should encode");
220+
let mut words: Vec<&str> = mnemonic.split(' ').collect();
221+
words[24] = if words[24] == "abandon" {
222+
"ability"
223+
} else {
224+
"abandon"
225+
};
226+
let broken = words.join(" ");
227+
228+
let err = seed_from_mnemonic(&broken).expect_err("should fail checksum");
229+
assert_eq!(err, MnemonicError::FailedToDecodeMnemonic);
230+
assert_eq!(err.to_string(), FAIL_TO_DECODE_MNEMONIC_ERROR_MSG);
231+
}
232+
233+
#[test]
234+
fn rejects_wrong_seed_length() {
235+
let err = mnemonic_from_seed(&[1u8; 31]).expect_err("length mismatch should fail");
236+
assert_eq!(
237+
err,
238+
MnemonicError::InvalidSeedLength {
239+
expected: SEED_BYTES_LENGTH,
240+
found: 31
241+
}
242+
);
243+
}
244+
}

crates/algokit_crypto/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1+
pub mod algo25;
12
pub mod ed25519;
2-
33
pub use signature::{Keypair, Signer};

crates/algokit_crypto_ffi/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ uniffi = { workspace = true, features = [
1818
async-trait = "0.1.89"
1919
signature = "2.2.0"
2020
tokio = { version = "1.49.0", features = ["rt", "time", "net", "io-util", "sync"] }
21+
getrandom = "0.4.1"
2122

2223
[dev-dependencies]
2324
tokio = { version = "1.49.0", features = ["macros", "rt"] }

0 commit comments

Comments
 (0)