ic_canister_kit/number/
mix.rs1use candid::CandidType;
6use serde::{Deserialize, Serialize};
7
8const CHECKSUM_LENGTH: usize = 4;
9const MAX_INDEX_LENGTH: usize = std::mem::size_of::<u64>();
10const MAX_ENCODED_LENGTH: usize = CHECKSUM_LENGTH + MAX_INDEX_LENGTH * 2;
11
12#[inline]
14pub fn encode_index_code(salt: &[u8], index: u64, random: Option<&[u8]>) -> Vec<u8> {
15 let trimmed = trim_index(index); let mix = mix_numbers(&trimmed, random); use sha2::Digest;
19 let mut hasher = sha2::Sha256::new();
20 hasher.update(&mix);
21 hasher.update(salt); let digest: [u8; 32] = hasher.finalize().into(); let mut show = Vec::with_capacity(mix.len() + 4);
25
26 show.extend_from_slice(&digest[..CHECKSUM_LENGTH]); show.extend_from_slice(&mix); show
31}
32
33#[inline]
35pub fn encode_index_code_with_base32(salt: &[u8], index: u64, random: Option<&[u8]>) -> String {
36 let show = encode_index_code(salt, index, random);
37 base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &show)
38}
39
40#[derive(CandidType, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
42pub enum MixNumberDecodeError {
43 WrongLength,
45 WrongChecksum,
47 Base32DecodeError,
49}
50impl std::fmt::Display for MixNumberDecodeError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(
53 f,
54 "{}",
55 match self {
56 MixNumberDecodeError::WrongLength => "Wrong mix number length",
57 MixNumberDecodeError::WrongChecksum => "Wrong mix number checksum",
58 MixNumberDecodeError::Base32DecodeError => "base32 decode failed",
59 }
60 )
61 }
62}
63impl std::error::Error for MixNumberDecodeError {}
64
65pub fn decode_index_code(salt: &[u8], show: &[u8]) -> Result<u64, MixNumberDecodeError> {
67 if show.len() < CHECKSUM_LENGTH + 2 || MAX_ENCODED_LENGTH < show.len() || !show.len().is_multiple_of(2) {
68 return Err(MixNumberDecodeError::WrongLength); }
70 let mix = &show[CHECKSUM_LENGTH..];
71
72 use sha2::Digest;
73 let mut hasher = sha2::Sha256::new();
74 hasher.update(mix);
75 hasher.update(salt); let digest: [u8; 32] = hasher.finalize().into(); if show[..CHECKSUM_LENGTH] != digest[..CHECKSUM_LENGTH] {
79 return Err(MixNumberDecodeError::WrongChecksum); }
81
82 let trimmed = restore_numbers(mix)?;
83
84 let index = restore_index(&trimmed)?;
85
86 Ok(index)
87}
88pub fn decode_index_code_by_base32(salt: &[u8], code: &str) -> Result<u64, MixNumberDecodeError> {
90 let show = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, code)
91 .ok_or(MixNumberDecodeError::Base32DecodeError)?;
92 decode_index_code(salt, &show)
93}
94
95fn trim_index(index: u64) -> Vec<u8> {
100 let bytes = index.to_be_bytes();
101 let trimmed: Vec<u8> = bytes.into_iter().skip_while(|n| *n == 0).collect();
102 if trimmed.is_empty() { vec![0] } else { trimmed }
103}
104
105#[allow(clippy::identity_op)]
107fn restore_index(numbers: &[u8]) -> Result<u64, MixNumberDecodeError> {
108 if numbers.is_empty() || MAX_INDEX_LENGTH < numbers.len() {
109 return Err(MixNumberDecodeError::WrongLength);
110 }
111
112 let mut bytes = [0_u8; 8];
113
114 let len = numbers.len();
115 for i in 0..len {
116 bytes[8 - len + i] = numbers[i];
117 }
118
119 Ok(u64::from_be_bytes(bytes))
120}
121
122fn mix_numbers(numbers: &[u8], random: Option<&[u8]>) -> Vec<u8> {
124 #[allow(clippy::identity_op)]
125 fn mix_single(m: u8, n: u8) -> [u8; 2] {
126 [
127 0b0000_0000
128 | ((m & 0b1000_0000) >> 0) | ((m & 0b0100_0000) >> 1) | ((m & 0b0010_0000) >> 2) | ((m & 0b0001_0000) >> 3) | ((n & 0b1000_0000) >> 1) | ((n & 0b0100_0000) >> 2) | ((n & 0b0010_0000) >> 3) | ((n & 0b0001_0000) >> 4), 0b0000_0000
137 | ((m & 0b0000_1000) << 4) | ((m & 0b0000_0100) << 3) | ((m & 0b0000_0010) << 2) | ((m & 0b0000_0001) << 1) | ((n & 0b0000_1000) << 3) | ((n & 0b0000_0100) << 2) | ((n & 0b0000_0010) << 1) | ((n & 0b0000_0001) << 0), ]
146 }
147
148 let random = random.unwrap_or_default();
149
150 let mut ns = Vec::with_capacity(numbers.len() * 2);
151 for (i, n) in numbers.iter().enumerate() {
152 ns.extend_from_slice(&mix_single(random.get(i).copied().unwrap_or(0), *n));
153 }
154 ns
155}
156
157fn restore_numbers(ns: &[u8]) -> Result<Vec<u8>, MixNumberDecodeError> {
159 #[allow(clippy::identity_op)]
160 fn restore_single(n1: u8, n2: u8) -> u8 {
161 0b0000_0000
162 | ((n1 & 0b0100_0000) << 1)
163 | ((n1 & 0b0001_0000) << 2)
164 | ((n1 & 0b0000_0100) << 3)
165 | ((n1 & 0b0000_0001) << 4)
166 | ((n2 & 0b0100_0000) >> 3)
167 | ((n2 & 0b0001_0000) >> 2)
168 | ((n2 & 0b0000_0100) >> 1)
169 | ((n2 & 0b0000_0001) >> 0)
170 }
171
172 if ns.is_empty() || !ns.len().is_multiple_of(2) || MAX_INDEX_LENGTH * 2 < ns.len() {
173 return Err(MixNumberDecodeError::WrongLength);
174 }
175
176 let mut numbers = Vec::new();
177 for i in 0..(ns.len() / 2) {
178 numbers.push(restore_single(ns[i * 2], ns[i * 2 + 1]));
179 }
180 Ok(numbers)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::{MixNumberDecodeError, decode_index_code, decode_index_code_by_base32, encode_index_code};
186
187 #[test]
188 fn round_trips_boundary_values() {
189 let salt = b"private-salt";
190 for value in [0, 1, 255, 256, u64::MAX] {
191 let encoded = encode_index_code(salt, value, Some(b"random!!"));
192 assert_eq!(decode_index_code(salt, &encoded), Ok(value));
193 }
194 }
195
196 #[test]
197 fn rejects_invalid_checksum_and_oversized_payload() {
198 let salt = b"private-salt";
199 let mut encoded = encode_index_code(salt, 42, None);
200 encoded[0] ^= 1;
201 assert!(matches!(
202 decode_index_code(salt, &encoded),
203 Err(MixNumberDecodeError::WrongChecksum)
204 ));
205
206 assert!(matches!(
207 decode_index_code(salt, &[0; 22]),
208 Err(MixNumberDecodeError::WrongLength)
209 ));
210 }
211
212 #[test]
213 fn rejects_invalid_base32_without_panicking() {
214 assert!(matches!(
215 decode_index_code_by_base32(b"salt", "***"),
216 Err(MixNumberDecodeError::Base32DecodeError)
217 ));
218 }
219}