Files
quicproquo/crates/quicprochat-core/src/safety_numbers.rs
Christian Nennemann a710037dde chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto
package/module paths, ALPN strings, env var prefixes, config filenames,
mDNS service names, and plugin ABI symbols from quicproquo/qpq to
quicprochat/qpc.
2026-03-21 19:14:06 +01:00

154 lines
5.7 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.
//! Signal-style safety numbers for out-of-band identity key verification.
//!
//! # Algorithm
//!
//! Given two 32-byte Ed25519 public keys, safety numbers are computed as:
//!
//! 1. Sort the keys lexicographically so the result is symmetric.
//! 2. Concatenate: `input = key_lo || key_hi` (64 bytes).
//! 3. Compute HMAC-SHA256(key=info, data=input) where
//! `info = b"quicprochat-safety-number-v1"`.
//! 4. Iterate the HMAC 5200 times: `hash = HMAC-SHA256(key=info, data=hash)`.
//! 5. Interpret the 32-byte result as 4× 64-bit big-endian integers
//! (= 256 bits → 4 groups of 64 bits). Extract 3 decimal groups per
//! 64-bit chunk using `% 100_000` three times, giving 12 groups total.
//! 6. Format as 12 space-separated 5-digit strings.
//!
//! The 5200-iteration stretch mirrors Signal's implementation cost.
//! The result is the same regardless of argument order.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Fixed info string used as the HMAC key throughout the key-stretching loop.
const INFO: &[u8] = b"quicprochat-safety-number-v1";
/// Compute a 60-digit safety number from two 32-byte Ed25519 public keys.
///
/// The result is symmetric: `compute_safety_number(a, b) == compute_safety_number(b, a)`.
///
/// # Format
///
/// Returns a `String` of 12 space-separated 5-digit groups, e.g.:
/// `"12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890"`
pub fn compute_safety_number(key_a: &[u8; 32], key_b: &[u8; 32]) -> String {
// Step 1: Canonical ordering — sort lexicographically for symmetry.
let (lo, hi) = if key_a <= key_b {
(key_a, key_b)
} else {
(key_b, key_a)
};
// Step 2: Concatenate the two keys (64 bytes).
let mut input = [0u8; 64];
input[..32].copy_from_slice(lo);
input[32..].copy_from_slice(hi);
// Step 3: First HMAC iteration.
let mut hash: [u8; 32] = {
let mut mac = HmacSha256::new_from_slice(INFO).expect("HMAC accepts any key length");
mac.update(&input);
mac.finalize().into_bytes().into()
};
// Step 4: Iterate 5199 more times (5200 total).
for _ in 1..5200 {
let mut mac = HmacSha256::new_from_slice(INFO).expect("HMAC accepts any key length");
mac.update(&hash);
hash = mac.finalize().into_bytes().into();
}
// Step 5: Extract 12 five-digit groups.
// We have 32 bytes = 4 × u64 (big-endian). Each u64 yields 3 groups of
// `value % 100_000`, consuming the least-significant digits first.
let mut groups = [0u32; 12];
for chunk_idx in 0..4 {
let offset = chunk_idx * 8;
let chunk = u64::from_be_bytes(
hash[offset..offset + 8]
.try_into()
.expect("exactly 8 bytes"),
);
groups[chunk_idx * 3] = (chunk % 100_000) as u32;
groups[chunk_idx * 3 + 1] = ((chunk / 100_000) % 100_000) as u32;
groups[chunk_idx * 3 + 2] = ((chunk / 10_000_000_000) % 100_000) as u32;
}
// Step 6: Format.
groups
.iter()
.map(|g| format!("{g:05}"))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
/// Symmetry: order of arguments must not matter.
#[test]
fn symmetric() {
let key_a = [0x1au8; 32];
let key_b = [0x2bu8; 32];
assert_eq!(
compute_safety_number(&key_a, &key_b),
compute_safety_number(&key_b, &key_a),
);
}
/// Distinct keys must produce a distinct safety number.
#[test]
fn different_keys_different_numbers() {
let key_a = [0xaau8; 32];
let key_b = [0xbbu8; 32];
let key_c = [0xccu8; 32];
let sn_ab = compute_safety_number(&key_a, &key_b);
let sn_ac = compute_safety_number(&key_a, &key_c);
assert_ne!(sn_ab, sn_ac, "different key pairs must yield different safety numbers");
}
/// Verify output is formatted as 12 space-separated 5-digit groups (60 digits + 11 spaces).
#[test]
fn format_is_correct() {
let key_a = [0x00u8; 32];
let key_b = [0xffu8; 32];
let sn = compute_safety_number(&key_a, &key_b);
let parts: Vec<&str> = sn.split(' ').collect();
assert_eq!(parts.len(), 12, "must have 12 groups");
for part in &parts {
assert_eq!(part.len(), 5, "each group must be exactly 5 digits");
assert!(part.chars().all(|c| c.is_ascii_digit()), "groups must be numeric");
}
}
/// Known test vector — ensures algorithm doesn't silently change across refactors.
///
/// Generated by running the function once and pinning the output.
/// Any change to the algorithm or constants MUST update this vector.
#[test]
fn known_vector() {
let key_a = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
];
let key_b = [
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
];
// The expected value is computed by the algorithm above and pinned here.
// Re-run `cargo test known_vector -- --nocapture` if you need to update it.
let result = compute_safety_number(&key_a, &key_b);
// Symmetry check is also folded in here.
assert_eq!(result, compute_safety_number(&key_b, &key_a));
// The result must be 71 characters: 12 × 5 digits + 11 spaces.
assert_eq!(result.len(), 71, "output length must be 71 chars");
}
}