static WORDS: &[&str] = &[
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd",
"abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire",
"across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address",
"adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid",
"again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
"alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already",
"also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst",
"anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual",
"another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear",
"apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed",
"armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist",
"artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete",
"atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt",
"author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome",
"awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony",
"ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic",
"basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin",
"behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better",
"between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter",
"black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom",
"blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus",
"book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy",
"bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
"bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown",
"brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle",
"bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz",
"cabbage", "cabin", "cable",
];
pub fn short_auth_code(id_a: &[u8; 32], id_b: &[u8; 32], secret: &[u8; 32]) -> String {
let (lo, hi) = if id_a <= id_b {
(id_a, id_b)
} else {
(id_b, id_a)
};
let mut h = blake3::Hasher::new();
h.update(b"mcpmesh/pair/sas/1"); h.update(lo);
h.update(hi);
h.update(secret);
let digest = h.finalize();
let bytes = digest.as_bytes();
let n = WORDS.len();
debug_assert!(
!WORDS.is_empty(),
"WORDS must be non-empty for `% n` to be safe"
);
(0..3)
.map(|i| WORDS[bytes[i] as usize % n])
.collect::<Vec<_>>()
.join("-")
}
pub fn fingerprint_words(pk_bytes: &[u8]) -> String {
let mut h = blake3::Hasher::new();
h.update(b"mcpmesh/roster/org-root-fingerprint/1"); h.update(pk_bytes);
let digest = h.finalize();
let bytes = digest.as_bytes();
let n = WORDS.len();
debug_assert!(
!WORDS.is_empty(),
"WORDS must be non-empty for `% n` to be safe"
);
(0..4)
.map(|i| WORDS[bytes[i] as usize % n])
.collect::<Vec<_>>()
.join("-")
}
pub fn join_code_fingerprint(user_pk: &[u8], device_endpoint_id: &[u8]) -> String {
let mut h = blake3::Hasher::new();
h.update(b"mcpmesh/join/code-fingerprint/1");
h.update(user_pk);
h.update(device_endpoint_id);
let digest = h.finalize();
let bytes = digest.as_bytes();
let n = WORDS.len();
debug_assert!(!WORDS.is_empty());
(0..4)
.map(|i| WORDS[bytes[i] as usize % n])
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_transcript_yields_same_code_order_independent_endpoints() {
let a = [1u8; 32];
let b = [2u8; 32];
let secret = [9u8; 32];
let code1 = short_auth_code(&a, &b, &secret);
let code2 = short_auth_code(&b, &a, &secret); assert_eq!(code1, code2, "SAS must be endpoint-order-independent");
assert_ne!(code1, short_auth_code(&a, &b, &[8u8; 32]));
assert!(code1.split('-').count() >= 3);
}
#[test]
fn fingerprint_words_is_deterministic_and_shaped() {
let a = fingerprint_words(&[7u8; 32]);
assert_eq!(a, fingerprint_words(&[7u8; 32])); assert_ne!(a, fingerprint_words(&[8u8; 32])); assert_eq!(a.split('-').count(), 4); assert!(!a.contains("b64u"));
}
#[test]
fn join_code_fingerprint_is_deterministic_and_binds_the_user_pk() {
let a = join_code_fingerprint(&[1u8; 32], &[2u8; 32]);
assert_eq!(a, join_code_fingerprint(&[1u8; 32], &[2u8; 32])); assert_ne!(a, join_code_fingerprint(&[9u8; 32], &[2u8; 32])); assert_ne!(a, join_code_fingerprint(&[1u8; 32], &[9u8; 32])); assert_ne!(a, fingerprint_words(&[1u8; 32]));
assert_eq!(a.split('-').count(), 4);
assert!(!a.contains("b64u"));
}
#[test]
fn wordlist_is_at_least_256_and_has_no_duplicates() {
assert!(
WORDS.len() >= 256,
"SAS needs >= 256 words for ~8 bits/word (got {})",
WORDS.len()
);
let mut sorted = WORDS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
WORDS.len(),
"SAS wordlist must have no duplicates"
);
}
}