initial commit

Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
This commit is contained in:
Kamal Tufekcic 2026-04-02 23:48:10 +03:00
commit d73755a275
No known key found for this signature in database
165830 changed files with 568244 additions and 0 deletions

243
soliton/src/verification.rs Normal file
View file

@ -0,0 +1,243 @@
//! 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)
));
}
}