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.
154 lines
5.7 KiB
Rust
154 lines
5.7 KiB
Rust
//! 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");
|
||
}
|
||
}
|