libsoliton/soliton/src/verification.rs
Kamal Tufekcic d73755a275
initial commit
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
2026-04-23 15:51:07 +03:00

243 lines
9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Key verification phrases.
//!
//! Generates a human-readable phrase from two identity public keys that both
//! parties can compare out-of-band to verify identity key continuity.
//! This is a display-layer concern outside the formal cryptographic model;
//! the phrase is a human-readable encoding of the fingerprint comparison.
//!
//! Algorithm:
//! 1. Sort the two public keys lexicographically.
//! 2. Concatenate with domain separation: `"lo-verification-v1" || sorted_pk_a || sorted_pk_b`.
//! 3. Compute `hash = SHA3-256(concatenation)`.
//! 4. Map 7 chunks of the hash to words from the EFF large wordlist (7776 words).
//!
//! Each word carries ~12.9 bits of entropy. 7 words ≈ 90.3 bits of
//! second-preimage resistance (the relevant metric for pairwise verification:
//! given a specific key pair, how hard is it to find another pair producing the
//! same phrase). Birthday-bound collision resistance is ~45 bits.
use crate::constants;
use crate::error::{Error, Result};
use crate::primitives::sha3_256;
// Generated by build.rs from the EFF large wordlist.
include!(concat!(env!("OUT_DIR"), "/eff_wordlist.rs"));
const _: () = assert!(
EFF_WORDLIST.len() == 7776,
"EFF_WORDLIST must contain exactly 7776 words"
);
/// Generate a verification phrase from two identity public keys.
///
/// The phrase is deterministic and order-independent: `verification_phrase(a, b)`
/// produces the same result as `verification_phrase(b, a)`.
///
/// Returns 7 space-separated words from the EFF large wordlist.
#[must_use = "verification phrase must be displayed to the user"]
pub fn verification_phrase(pk_a: &[u8], pk_b: &[u8]) -> Result<String> {
if pk_a.len() != constants::LO_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_PUBLIC_KEY_SIZE,
got: pk_a.len(),
});
}
if pk_b.len() != constants::LO_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_PUBLIC_KEY_SIZE,
got: pk_b.len(),
});
}
// Self-pair produces a valid phrase but verifies nothing — a UI bug
// passing the user's own key twice would give a false sense of security.
if pk_a == pk_b {
return Err(Error::InvalidData);
}
// Step 1: Sort keys lexicographically.
let (first, second) = if pk_a <= pk_b {
(pk_a, pk_b)
} else {
(pk_b, pk_a)
};
// Step 2: Concatenate with domain separation label and hash.
// The label prevents collisions with any other protocol function that
// hashes two concatenated public keys.
let mut input =
Vec::with_capacity(constants::PHRASE_HASH_LABEL.len() + first.len() + second.len());
input.extend_from_slice(constants::PHRASE_HASH_LABEL);
input.extend_from_slice(first);
input.extend_from_slice(second);
let mut hash = sha3_256::hash(&input);
// Step 3: Map hash bytes to word indices.
// We need 7 indices into a 7776-word list.
// Take 2 bytes per word (u16). Accept only values in 0..62208
// (62208 = 7776 * 8) to eliminate modular bias: val % 7776 is uniform
// for val in [0, 62208) because 62208 divides evenly. Rejection
// probability: 3328/65536 ≈ 5.1% per sample. Expected ~7.4 pairs
// consumed per phrase; ~8.6 spare slots in the first 32-byte hash
// before rehash. Across 20 rounds × 16 pairs = 320 samples,
// termination failure < 2^-150.
const LIMIT: u16 = 7776 * 8;
// Compile-time guard: if wordlist size or multiplier changes, the u16
// cast would silently truncate. This makes the invariant robust.
const _: () = assert!(7776 * 8 <= u16::MAX as u32);
let mut words = Vec::with_capacity(7);
let mut offset = 0;
let mut rehash_count = 0u32;
while words.len() < 7 {
// Rehash before reading if we've exhausted all 16 pairs (32 bytes).
if offset + 2 > 32 {
// Cap at 19 rehash rounds (range 1..=19). Each round has ~95%
// acceptance per sample, so failing to fill 7 words in
// 16 + 19 × 16 = 320 samples is astronomically improbable
// (~2^-150). This makes termination unconditional in the code
// rather than probabilistic.
rehash_count += 1;
if rehash_count >= 20 {
// Structurally unreachable: 320 samples with ~94.9% acceptance
// each → failure probability < 2^-150. Returns Internal rather
// than unreachable!() to preserve panic=abort safety.
return Err(Error::Internal);
}
// Include the round counter as a single byte to make each rehash
// round a distinct function, preventing degenerate hash chain cycles.
// rehash_count is in [1, 20], fits in u8. try_from makes this
// fail-fast if the cap is ever raised above 255.
let mut expand_input = Vec::with_capacity(19 + 1 + 32);
expand_input.extend_from_slice(constants::PHRASE_EXPAND_LABEL);
expand_input.push(u8::try_from(rehash_count).map_err(|_| Error::Internal)?);
expand_input.extend_from_slice(&hash);
hash = sha3_256::hash(&expand_input);
offset = 0;
}
let val = u16::from_be_bytes([hash[offset], hash[offset + 1]]);
offset += 2;
if val < LIMIT {
let index = (val as usize) % EFF_WORDLIST.len();
words.push(EFF_WORDLIST[index]);
}
}
Ok(words.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::identity::{GeneratedIdentity, generate_identity};
#[test]
fn deterministic() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase1 = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let phrase2 = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
assert_eq!(phrase1, phrase2);
}
#[test]
fn order_independent() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let ab = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let ba = verification_phrase(pk_b.as_bytes(), pk_a.as_bytes()).unwrap();
assert_eq!(ab, ba);
}
#[test]
fn seven_words() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let words: Vec<&str> = phrase.split(' ').collect();
assert_eq!(words.len(), 7);
}
#[test]
fn all_words_in_wordlist() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
for word in phrase.split(' ') {
assert!(
EFF_WORDLIST.contains(&word),
"word '{}' not in EFF_WORDLIST",
word
);
}
}
#[test]
fn different_keys_differ() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_c, ..
} = generate_identity().unwrap();
let phrase_ab = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let phrase_ac = verification_phrase(pk_a.as_bytes(), pk_c.as_bytes()).unwrap();
assert_ne!(phrase_ab, phrase_ac);
}
#[test]
fn wrong_pk_a_size() {
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(&[0u8; 100], pk_b.as_bytes()),
Err(Error::InvalidLength {
expected: 3200,
got: 100
})
));
}
#[test]
fn wrong_pk_b_size() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(pk_a.as_bytes(), &[0u8; 100]),
Err(Error::InvalidLength {
expected: 3200,
got: 100
})
));
}
#[test]
fn self_pair_rejected() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(pk_a.as_bytes(), pk_a.as_bytes()),
Err(Error::InvalidData)
));
}
}