2026-02-11 19:51:22 +01:00
|
|
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
|
|
|
use chat_proto::logoschat::intro::IntroBundle;
|
|
|
|
|
use crypto::Ed25519Signature;
|
|
|
|
|
use prost::Message;
|
|
|
|
|
use rand_core::{CryptoRng, RngCore};
|
|
|
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
2026-01-26 23:53:44 +07:00
|
|
|
|
|
|
|
|
use crate::errors::ChatError;
|
|
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
const BUNDLE_PREFIX: &str = "logos_chatintro_1_";
|
|
|
|
|
|
|
|
|
|
fn intro_binding_message(ephemeral: &PublicKey) -> Vec<u8> {
|
|
|
|
|
let mut message = Vec::with_capacity(BUNDLE_PREFIX.len() + 32);
|
|
|
|
|
message.extend_from_slice(BUNDLE_PREFIX.as_bytes());
|
|
|
|
|
message.extend_from_slice(ephemeral.as_bytes());
|
|
|
|
|
message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn sign_intro_binding<R: RngCore + CryptoRng>(
|
|
|
|
|
secret: &StaticSecret,
|
|
|
|
|
ephemeral: &PublicKey,
|
|
|
|
|
rng: R,
|
|
|
|
|
) -> Ed25519Signature {
|
|
|
|
|
let message = intro_binding_message(ephemeral);
|
|
|
|
|
crypto::xeddsa_sign(secret, &message, rng)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn verify_intro_binding(
|
|
|
|
|
pubkey: &PublicKey,
|
|
|
|
|
ephemeral: &PublicKey,
|
|
|
|
|
signature: &Ed25519Signature,
|
|
|
|
|
) -> Result<(), crypto::SignatureError> {
|
|
|
|
|
let message = intro_binding_message(ephemeral);
|
|
|
|
|
crypto::xeddsa_verify(pubkey, &message, signature)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 23:53:44 +07:00
|
|
|
/// Supplies remote participants with the required keys to use Inbox protocol
|
|
|
|
|
pub struct Introduction {
|
2026-02-11 19:51:22 +01:00
|
|
|
installation_key: PublicKey,
|
|
|
|
|
ephemeral_key: PublicKey,
|
|
|
|
|
signature: Ed25519Signature,
|
2026-01-26 23:53:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
impl Introduction {
|
|
|
|
|
/// Create a new `Introduction` by signing the ephemeral key with the installation secret.
|
|
|
|
|
pub(crate) fn new<R: RngCore + CryptoRng>(
|
|
|
|
|
installation_secret: &StaticSecret,
|
|
|
|
|
ephemeral_key: PublicKey,
|
|
|
|
|
rng: R,
|
|
|
|
|
) -> Self {
|
|
|
|
|
let installation_key = installation_secret.into();
|
|
|
|
|
let signature = sign_intro_binding(installation_secret, &ephemeral_key, rng);
|
|
|
|
|
Self {
|
|
|
|
|
installation_key,
|
|
|
|
|
ephemeral_key,
|
|
|
|
|
signature,
|
2026-01-26 23:53:44 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 19:51:22 +01:00
|
|
|
|
|
|
|
|
pub fn installation_key(&self) -> &PublicKey {
|
|
|
|
|
&self.installation_key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn ephemeral_key(&self) -> &PublicKey {
|
|
|
|
|
&self.ephemeral_key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn signature(&self) -> &Ed25519Signature {
|
|
|
|
|
&self.signature
|
|
|
|
|
}
|
2026-01-26 23:53:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-10 18:34:33 +01:00
|
|
|
impl From<Introduction> for Vec<u8> {
|
2026-02-11 19:51:22 +01:00
|
|
|
fn from(intro: Introduction) -> Vec<u8> {
|
|
|
|
|
let bundle = IntroBundle {
|
|
|
|
|
installation_pubkey: prost::bytes::Bytes::copy_from_slice(
|
|
|
|
|
intro.installation_key.as_bytes(),
|
|
|
|
|
),
|
|
|
|
|
ephemeral_pubkey: prost::bytes::Bytes::copy_from_slice(intro.ephemeral_key.as_bytes()),
|
|
|
|
|
signature: prost::bytes::Bytes::copy_from_slice(intro.signature.as_ref()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let base64_encoded = URL_SAFE_NO_PAD.encode(bundle.encode_to_vec());
|
|
|
|
|
|
|
|
|
|
let mut result = String::with_capacity(BUNDLE_PREFIX.len() + base64_encoded.len());
|
|
|
|
|
result.push_str(BUNDLE_PREFIX);
|
|
|
|
|
result.push_str(&base64_encoded);
|
2026-01-26 23:53:44 +07:00
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
result.into_bytes()
|
2026-01-26 23:53:44 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 01:38:08 +07:00
|
|
|
impl TryFrom<&[u8]> for Introduction {
|
2026-01-26 23:53:44 +07:00
|
|
|
type Error = ChatError;
|
|
|
|
|
|
2026-01-29 01:38:08 +07:00
|
|
|
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
2026-02-11 19:51:22 +01:00
|
|
|
let str_value = std::str::from_utf8(value)
|
|
|
|
|
.map_err(|_| ChatError::BadBundleValue("invalid UTF-8".into()))?;
|
2026-01-26 23:53:44 +07:00
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
let base64_part = str_value.strip_prefix(BUNDLE_PREFIX).ok_or_else(|| {
|
|
|
|
|
ChatError::BadBundleValue("not recognized as an introduction bundle".into())
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let proto_bytes = URL_SAFE_NO_PAD
|
|
|
|
|
.decode(base64_part)
|
|
|
|
|
.map_err(|_| ChatError::BadBundleValue("invalid base64".into()))?;
|
|
|
|
|
|
|
|
|
|
let bundle = IntroBundle::decode(proto_bytes.as_slice())
|
|
|
|
|
.map_err(|_| ChatError::BadBundleValue("invalid protobuf".into()))?;
|
2026-01-26 23:53:44 +07:00
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
let installation_bytes: [u8; 32] = bundle
|
|
|
|
|
.installation_pubkey
|
|
|
|
|
.as_ref()
|
2026-01-26 23:53:44 +07:00
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| ChatError::InvalidKeyLength)?;
|
|
|
|
|
|
2026-02-11 19:51:22 +01:00
|
|
|
let ephemeral_bytes: [u8; 32] = bundle
|
|
|
|
|
.ephemeral_pubkey
|
|
|
|
|
.as_ref()
|
2026-01-26 23:53:44 +07:00
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| ChatError::InvalidKeyLength)?;
|
2026-02-11 19:51:22 +01:00
|
|
|
|
|
|
|
|
let signature_bytes: [u8; 64] = bundle
|
|
|
|
|
.signature
|
|
|
|
|
.as_ref()
|
|
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| ChatError::BadBundleValue("invalid signature length".into()))?;
|
|
|
|
|
|
|
|
|
|
let installation_key = PublicKey::from(installation_bytes);
|
2026-01-26 23:53:44 +07:00
|
|
|
let ephemeral_key = PublicKey::from(ephemeral_bytes);
|
2026-02-11 19:51:22 +01:00
|
|
|
let signature = Ed25519Signature(signature_bytes);
|
|
|
|
|
|
|
|
|
|
verify_intro_binding(&installation_key, &ephemeral_key, &signature)
|
|
|
|
|
.map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?;
|
2026-01-26 23:53:44 +07:00
|
|
|
|
|
|
|
|
Ok(Introduction {
|
|
|
|
|
installation_key,
|
|
|
|
|
ephemeral_key,
|
2026-02-11 19:51:22 +01:00
|
|
|
signature,
|
2026-01-26 23:53:44 +07:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 19:51:22 +01:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use rand_core::OsRng;
|
|
|
|
|
|
|
|
|
|
fn create_test_introduction() -> Introduction {
|
|
|
|
|
let install_secret = StaticSecret::random_from_rng(OsRng);
|
|
|
|
|
|
|
|
|
|
let ephemeral_secret = StaticSecret::random_from_rng(OsRng);
|
|
|
|
|
let ephemeral_pub: PublicKey = (&ephemeral_secret).into();
|
|
|
|
|
|
|
|
|
|
Introduction::new(&install_secret, ephemeral_pub, OsRng)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_serialization_roundtrip() {
|
|
|
|
|
let intro = create_test_introduction();
|
|
|
|
|
let original_install = *intro.installation_key();
|
|
|
|
|
let original_ephemeral = *intro.ephemeral_key();
|
|
|
|
|
let original_signature = *intro.signature();
|
|
|
|
|
|
|
|
|
|
let encoded: Vec<u8> = intro.into();
|
|
|
|
|
let decoded = Introduction::try_from(encoded.as_slice()).unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(*decoded.installation_key(), original_install);
|
|
|
|
|
assert_eq!(*decoded.ephemeral_key(), original_ephemeral);
|
|
|
|
|
assert_eq!(*decoded.signature(), original_signature);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_prefix_rejected() {
|
|
|
|
|
assert!(Introduction::try_from(b"wrong_prefix_AAAA".as_slice()).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_base64_rejected() {
|
|
|
|
|
assert!(Introduction::try_from(b"logos_chatintro_1_!!!invalid!!!".as_slice()).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_truncated_payload_rejected() {
|
|
|
|
|
let intro = create_test_introduction();
|
|
|
|
|
let encoded: Vec<u8> = intro.into();
|
|
|
|
|
let encoded_str = String::from_utf8(encoded).unwrap();
|
|
|
|
|
|
|
|
|
|
let truncated = format!(
|
|
|
|
|
"logos_chatintro_1_{}",
|
|
|
|
|
&encoded_str[BUNDLE_PREFIX.len()..][..10]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(Introduction::try_from(truncated.as_bytes()).is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|