use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const LOGIN_TYPE: &str = "org.m4a.login.signature";
pub const CHALLENGE_TTL_MS: i64 = 60_000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Challenge {
pub challenge_id: String,
pub nonce: String,
pub expires_ms: i64,
pub audience: String,
}
pub fn key_id_of(public_key: &[u8]) -> String {
format!("k{}", &hex::encode(Sha256::digest(public_key))[..32])
}
pub fn encode(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn decode(text: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD.decode(text.as_bytes()).ok()
}
pub fn login_message(c: &Challenge, key_id: &str) -> Vec<u8> {
format!("m4a-key-login-v1\n{}\n{}\n{}\n{}\n{}\n", c.audience, key_id, c.challenge_id, c.nonce, c.expires_ms).into_bytes()
}
pub fn invite_hash(invite_code: &str) -> String {
hex::encode(Sha256::digest(invite_code.as_bytes()))
}
pub fn enroll_message(audience: &str, invite_code: &str, public_key_b64: &str) -> Vec<u8> {
let key_id = decode(public_key_b64).map(|k| key_id_of(&k)).unwrap_or_default();
format!("m4a-key-enroll-v1\n{}\n{}\n{}\n{}\n", audience, invite_hash(invite_code), key_id, public_key_b64).into_bytes()
}
pub fn verify(public_key_b64: &str, message: &[u8], signature_b64: &str) -> bool {
let Some(pk) = decode(public_key_b64).and_then(|b| <[u8; 32]>::try_from(b).ok()) else { return false };
let Some(sig) = decode(signature_b64).and_then(|b| <[u8; 64]>::try_from(b).ok()) else { return false };
let Ok(vk) = VerifyingKey::from_bytes(&pk) else { return false };
vk.verify_strict(message, &Signature::from_bytes(&sig)).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn key() -> (SigningKey, String) {
let sk = SigningKey::generate(&mut rand::thread_rng());
let pk = encode(sk.verifying_key().as_bytes());
(sk, pk)
}
fn challenge() -> Challenge {
Challenge { challenge_id: "c1".into(), nonce: "n-0123".into(), expires_ms: 1_000, audience: "aud-a".into() }
}
#[test]
fn login_signature_verifies_and_every_field_is_bound() {
let (sk, pk) = key();
let kid = key_id_of(&decode(&pk).unwrap());
assert!(kid.starts_with('k') && kid.len() == 33);
let c = challenge();
let sig = encode(&sk.sign(&login_message(&c, &kid)).to_bytes());
assert!(verify(&pk, &login_message(&c, &kid), &sig));
for tweak in [
Challenge { audience: "aud-b".into(), ..c.clone() },
Challenge { nonce: "n-other".into(), ..c.clone() },
Challenge { challenge_id: "c2".into(), ..c.clone() },
Challenge { expires_ms: 2_000, ..c.clone() },
] {
assert!(!verify(&pk, &login_message(&tweak, &kid), &sig), "{tweak:?}");
}
assert!(!verify(&pk, &login_message(&c, "kother"), &sig));
let (_, other_pk) = key();
assert!(!verify(&other_pk, &login_message(&c, &kid), &sig));
}
#[test]
fn enroll_signature_binds_invite_audience_and_key() {
let (sk, pk) = key();
let sig = encode(&sk.sign(&enroll_message("aud-a", "invite-1", &pk)).to_bytes());
assert!(verify(&pk, &enroll_message("aud-a", "invite-1", &pk), &sig));
assert!(!verify(&pk, &enroll_message("aud-b", "invite-1", &pk), &sig));
assert!(!verify(&pk, &enroll_message("aud-a", "invite-2", &pk), &sig));
}
#[test]
fn garbage_is_refused_not_a_panic() {
assert!(!verify("", b"x", ""));
assert!(!verify("AAAA", b"x", "AAAA"));
assert!(!verify(&encode(&[0u8; 32]), b"x", &encode(&[0u8; 64])));
assert!(decode("not base64 !!").is_none());
}
}