use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use rand_core::{CryptoRng, RngCore};
use sha2_pake::Sha256;
use spake2::{Ed25519Group, Identity, Password, Spake2};
type HmacSha256 = Hmac<Sha256>;
pub mod words;
pub type PakeState = Spake2<Ed25519Group>;
pub fn os_rng() -> impl RngCore + CryptoRng {
OsRngCompat
}
const IDENTITY_PREFIX: &[u8] = b"filament-pair-pake-v1:";
const SECRET_INFO: &[u8] = b"filament-pair-pake-v1:pinned-secret";
const CONFIRM_LABEL: &[u8] = b"filament-pake-confirm-v1";
fn identity_bytes(nameplate: &[u8]) -> Vec<u8> {
let mut v = IDENTITY_PREFIX.to_vec();
v.extend_from_slice(nameplate);
v
}
pub fn start(password: &[u8], nameplate: &[u8]) -> (Spake2<Ed25519Group>, Vec<u8>) {
start_with_rng(password, nameplate, OsRngCompat)
}
pub fn start_with_rng<R: RngCore + CryptoRng>(
password: &[u8],
nameplate: &[u8],
rng: R,
) -> (Spake2<Ed25519Group>, Vec<u8>) {
Spake2::<Ed25519Group>::start_symmetric_with_rng(
&Password::new(password),
&Identity::new(&identity_bytes(nameplate)),
rng,
)
}
pub fn finish(state: Spake2<Ed25519Group>, peer_msg: &[u8]) -> Option<Vec<u8>> {
state.finish(peer_msg).ok()
}
pub fn confirm_mac(k: &[u8], dir: &str, fp_lo: &str, fp_hi: &str, caps: &str) -> Vec<u8> {
let mut m = <HmacSha256 as Mac>::new_from_slice(k).expect("HMAC accepts any key length");
m.update(CONFIRM_LABEL);
m.update(dir.as_bytes());
update_lp(&mut m, fp_lo.as_bytes());
update_lp(&mut m, fp_hi.as_bytes());
update_lp(&mut m, caps.as_bytes());
m.finalize().into_bytes().to_vec()
}
fn update_lp(m: &mut HmacSha256, field: &[u8]) {
m.update(&(field.len() as u32).to_le_bytes());
m.update(field);
}
pub fn confirm_dirs(my_fp: &str, fp_lo: &str) -> (&'static str, &'static str) {
if my_fp == fp_lo {
("A->B", "B->A") } else {
("B->A", "A->B") }
}
pub fn sort_fps<'a>(a: &'a str, b: &'a str) -> (&'a str, &'a str) {
if a < b { (a, b) } else { (b, a) }
}
pub fn our_confirm(k: &[u8], my_fp: &str, their_fp: &str, caps: &str) -> Vec<u8> {
let (lo, hi) = sort_fps(my_fp, their_fp);
let (send_dir, _expect) = confirm_dirs(my_fp, lo);
confirm_mac(k, send_dir, lo, hi, caps)
}
pub fn verify_peer_confirm(k: &[u8], my_fp: &str, their_fp: &str, caps: &str, received: &[u8]) -> bool {
let (lo, hi) = sort_fps(my_fp, their_fp);
let (_send, expect_dir) = confirm_dirs(my_fp, lo);
let expected = confirm_mac(k, expect_dir, lo, hi, caps);
ct_eq(&expected, received)
}
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut d = 0u8;
for (x, y) in a.iter().zip(b) {
d |= x ^ y;
}
d == 0
}
pub fn secret_from_k(k: &[u8]) -> String {
let hk = Hkdf::<Sha256>::new(None, k);
let mut out = [0u8; 32];
hk.expand(SECRET_INFO, &mut out)
.expect("32 bytes is a valid HKDF length");
hex::encode(out)
}
pub fn norm_code(raw: &str) -> String {
let lowered = raw.trim().to_lowercase();
let mut spaced = String::with_capacity(lowered.len());
let mut prev_ws = false;
for ch in lowered.chars() {
if ch.is_whitespace() {
if !prev_ws {
spaced.push('-');
}
prev_ws = true;
} else {
spaced.push(ch);
prev_ws = false;
}
}
let filtered: String = spaced.chars().filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-').collect();
filtered.chars().take(48).collect()
}
pub fn split_code(normalized: &str) -> (String, String) {
match normalized.rfind('-') {
Some(i) => (normalized[i + 1..].to_string(), normalized[..i].to_string()),
None => (normalized.to_string(), String::new()),
}
}
pub fn split_chosen_code(normalized: &str) -> (String, Option<String>) {
let trimmed = normalized.trim_matches('-');
if let Some(i) = trimmed.rfind('-') {
let after = &trimmed[i + 1..];
let before = &trimmed[..i];
if !before.is_empty()
&& (3..=5).contains(&after.len())
&& after.bytes().all(|b| b.is_ascii_digit())
{
return (before.to_string(), Some(after.to_string()));
}
}
(trimmed.to_string(), None)
}
pub fn canonical_caps(caps: &[String]) -> String {
let mut v: Vec<String> = caps
.iter()
.map(|c| c.trim().to_lowercase())
.filter(|c| !c.is_empty())
.collect();
v.sort();
v.dedup();
v.join(",")
}
struct OsRngCompat;
impl RngCore for OsRngCompat {
fn next_u32(&mut self) -> u32 {
let mut b = [0u8; 4];
self.fill_bytes(&mut b);
u32::from_le_bytes(b)
}
fn next_u64(&mut self) -> u64 {
let mut b = [0u8; 8];
self.fill_bytes(&mut b);
u64::from_le_bytes(b)
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
getrandom::getrandom(dest).expect("OS CSPRNG unavailable");
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
getrandom::getrandom(dest).map_err(|e| rand_core::Error::from(e.code()))
}
}
impl CryptoRng for OsRngCompat {}
#[cfg(target_arch = "wasm32")]
mod wasm {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct PakeSession {
state: Option<Spake2<Ed25519Group>>,
msg: Vec<u8>,
k: Option<Vec<u8>>,
}
#[wasm_bindgen]
impl PakeSession {
#[wasm_bindgen(constructor)]
pub fn new(password: &[u8], nameplate: &[u8]) -> PakeSession {
let (state, msg) = start(password, nameplate);
PakeSession { state: Some(state), msg, k: None }
}
#[wasm_bindgen(js_name = message)]
pub fn message(&self) -> Vec<u8> {
self.msg.clone()
}
#[wasm_bindgen(js_name = finish)]
pub fn finish_js(&mut self, peer_msg: &[u8]) -> bool {
let Some(state) = self.state.take() else { return false };
match super::finish(state, peer_msg) {
Some(k) => {
self.k = Some(k);
true
}
None => false,
}
}
#[wasm_bindgen(js_name = ourConfirm)]
pub fn our_confirm_js(&self, my_fp: &str, their_fp: &str, caps: &str) -> Option<Vec<u8>> {
self.k.as_ref().map(|k| super::our_confirm(k, my_fp, their_fp, caps))
}
#[wasm_bindgen(js_name = verifyPeerConfirm)]
pub fn verify_peer_confirm_js(&self, my_fp: &str, their_fp: &str, caps: &str, received: &[u8]) -> bool {
match self.k.as_ref() {
Some(k) => super::verify_peer_confirm(k, my_fp, their_fp, caps, received),
None => false,
}
}
#[wasm_bindgen(js_name = secret)]
pub fn secret_js(&self) -> Option<String> {
self.k.as_ref().map(|k| super::secret_from_k(k))
}
}
#[wasm_bindgen(js_name = canonicalCaps)]
pub fn canonical_caps_js(caps: Vec<JsValue>) -> String {
let v: Vec<String> = caps.into_iter().filter_map(|j| j.as_string()).collect();
super::canonical_caps(&v)
}
#[wasm_bindgen(js_name = ctEq)]
pub fn ct_eq_js(a: &[u8], b: &[u8]) -> bool {
super::ct_eq(a, b)
}
#[wasm_bindgen(js_name = normCode)]
pub fn norm_code_js(raw: &str) -> String {
super::norm_code(raw)
}
#[wasm_bindgen(js_name = splitCode)]
pub fn split_code_js(normalized: &str) -> Vec<JsValue> {
let (np, pw) = super::split_code(normalized);
vec![JsValue::from_str(&np), JsValue::from_str(&pw)]
}
#[wasm_bindgen(js_name = splitChosenCode)]
pub fn split_chosen_code_js(normalized: &str) -> Vec<JsValue> {
let (pw, np) = super::split_chosen_code(normalized);
vec![
JsValue::from_str(&pw),
JsValue::from_str(np.as_deref().unwrap_or("")),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
struct SeedRng {
state: [u8; 32],
counter: u64,
buf: [u8; 32],
pos: usize,
}
impl SeedRng {
fn new(seed: [u8; 32]) -> Self {
SeedRng { state: seed, counter: 0, buf: [0u8; 32], pos: 32 }
}
fn refill(&mut self) {
use sha2_pake::Digest;
let mut h = Sha256::new();
h.update(self.state);
h.update(self.counter.to_le_bytes());
self.buf.copy_from_slice(&h.finalize());
self.counter += 1;
self.pos = 0;
}
}
impl RngCore for SeedRng {
fn next_u32(&mut self) -> u32 {
let mut b = [0u8; 4];
self.fill_bytes(&mut b);
u32::from_le_bytes(b)
}
fn next_u64(&mut self) -> u64 {
let mut b = [0u8; 8];
self.fill_bytes(&mut b);
u64::from_le_bytes(b)
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
for d in dest.iter_mut() {
if self.pos >= 32 {
self.refill();
}
*d = self.buf[self.pos];
self.pos += 1;
}
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
self.fill_bytes(dest);
Ok(())
}
}
impl CryptoRng for SeedRng {}
const FP_A: &str = "SHA-256 AA:BB:CC";
const FP_B: &str = "SHA-256 DD:EE:FF";
#[test]
fn mutual_key_same_password_same_secret() {
let (sa, ma) = start_with_rng(b"brave-otter", b"314", SeedRng::new([1u8; 32]));
let (sb, mb) = start_with_rng(b"brave-otter", b"314", SeedRng::new([2u8; 32]));
let ka = finish(sa, &mb).unwrap();
let kb = finish(sb, &ma).unwrap();
let caps = canonical_caps(&["transfer".into()]);
let a_sends = our_confirm(&ka, FP_A, FP_B, &caps);
let b_sends = our_confirm(&kb, FP_B, FP_A, &caps);
assert!(verify_peer_confirm(&kb, FP_B, FP_A, &caps, &a_sends), "B verifies A");
assert!(verify_peer_confirm(&ka, FP_A, FP_B, &caps, &b_sends), "A verifies B");
assert_eq!(secret_from_k(&ka), secret_from_k(&kb));
assert_eq!(secret_from_k(&ka).len(), 64);
}
#[test]
fn reflection_is_rejected() {
let (sa, ma) = start_with_rng(b"brave-otter", b"314", SeedRng::new([1u8; 32]));
let (sb, mb) = start_with_rng(b"brave-otter", b"314", SeedRng::new([2u8; 32]));
let ka = finish(sa, &mb).unwrap();
let _kb = finish(sb, &ma).unwrap();
let caps = canonical_caps(&["transfer".into()]);
let a_sends = our_confirm(&ka, FP_A, FP_B, &caps);
assert!(!verify_peer_confirm(&ka, FP_A, FP_B, &caps, &a_sends), "reflection rejected");
}
#[test]
fn wrong_password_confirmation_fails() {
let (sa, ma) = start_with_rng(b"brave-otter", b"314", SeedRng::new([1u8; 32]));
let (sb, mb) = start_with_rng(b"tidy-walrus", b"314", SeedRng::new([2u8; 32]));
let ka = finish(sa, &mb).unwrap();
let kb = finish(sb, &ma).unwrap();
let caps = canonical_caps(&["transfer".into()]);
let a_sends = our_confirm(&ka, FP_A, FP_B, &caps);
assert!(!verify_peer_confirm(&kb, FP_B, FP_A, &caps, &a_sends));
}
#[test]
fn fingerprint_mismatch_confirmation_fails() {
let (sa, ma) = start_with_rng(b"brave-otter", b"314", SeedRng::new([1u8; 32]));
let (sb, mb) = start_with_rng(b"brave-otter", b"314", SeedRng::new([2u8; 32]));
let ka = finish(sa, &mb).unwrap();
let kb = finish(sb, &ma).unwrap();
let caps = canonical_caps(&["transfer".into()]);
let mitm_to_a = "SHA-256 99:MITM:A";
let mitm_to_b = "SHA-256 99:MITM:B";
let a_sends = our_confirm(&ka, FP_A, mitm_to_a, &caps);
assert!(!verify_peer_confirm(&kb, FP_B, mitm_to_b, &caps, &a_sends));
}
#[test]
fn caps_tamper_confirmation_fails() {
let (sa, ma) = start_with_rng(b"brave-otter", b"314", SeedRng::new([1u8; 32]));
let (sb, mb) = start_with_rng(b"brave-otter", b"314", SeedRng::new([2u8; 32]));
let ka = finish(sa, &mb).unwrap();
let kb = finish(sb, &ma).unwrap();
let a_sends = our_confirm(&ka, FP_A, FP_B, &canonical_caps(&["transfer".into()]));
let tampered_caps = canonical_caps(&["transfer".into(), "remote-exec".into()]);
assert!(!verify_peer_confirm(&kb, FP_B, FP_A, &tampered_caps, &a_sends));
}
#[test]
fn norm_and_split_match_expected() {
assert_eq!(norm_code(" Brave Otter Ruby 314 "), "brave-otter-ruby-314");
assert_eq!(norm_code("BRAVE-OTTER-314"), "brave-otter-314");
let (np, pw) = split_code(&norm_code("brave-otter-ruby-314"));
assert_eq!(np, "314");
assert_eq!(pw, "brave-otter-ruby");
let (np2, pw2) = split_code(&norm_code("brave otter 314"));
assert_eq!((np2.as_str(), pw2.as_str()), ("314", "brave-otter"));
}
#[test]
fn split_chosen_code_keeps_words_unless_numeric_nameplate() {
assert_eq!(
split_chosen_code("gigantic-element"),
("gigantic-element".to_string(), None)
);
assert_eq!(
split_chosen_code("gigantic-element-"),
("gigantic-element".to_string(), None)
);
assert_eq!(
split_chosen_code("-gigantic-element-"),
("gigantic-element".to_string(), None)
);
assert_eq!(
split_chosen_code("gigantic-element-9641"),
("gigantic-element".to_string(), Some("9641".to_string()))
);
assert_eq!(
split_chosen_code("gigantic-element-7"),
("gigantic-element-7".to_string(), None)
);
assert_eq!(split_chosen_code("cat"), ("cat".to_string(), None));
}
#[test]
fn full_pairing_from_spoken_code() {
let spoken = "Brave-Otter-Ruby-314";
let (np_c, pw_c) = split_code(&norm_code(spoken)); let (np_k, pw_k) = split_code(&norm_code(spoken)); assert_eq!((np_c.as_str(), pw_c.as_str()), (np_k.as_str(), pw_k.as_str()));
let (sa, ma) = start_with_rng(pw_c.as_bytes(), np_c.as_bytes(), SeedRng::new([7u8; 32]));
let (sb, mb) = start_with_rng(pw_k.as_bytes(), np_k.as_bytes(), SeedRng::new([8u8; 32]));
let ka = finish(sa, &mb).unwrap();
let kb = finish(sb, &ma).unwrap();
let caps = canonical_caps(&["transfer".into()]);
assert!(verify_peer_confirm(&kb, FP_B, FP_A, &caps, &our_confirm(&ka, FP_A, FP_B, &caps)));
assert_eq!(secret_from_k(&ka), secret_from_k(&kb));
}
#[test]
fn canonical_caps_is_order_independent() {
assert_eq!(
canonical_caps(&["Transfer".into(), "clipboard".into()]),
canonical_caps(&["clipboard".into(), "TRANSFER".into()])
);
assert_eq!(canonical_caps(&[]), "");
assert_eq!(canonical_caps(&["transfer".into(), "transfer".into()]), "transfer");
}
}