-
Notifications
You must be signed in to change notification settings - Fork 1
feat: update introduction bundle encoding #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,64 +1,196 @@ | ||
| use crypto::PrekeyBundle; | ||
| use x25519_dalek::PublicKey; | ||
| 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}; | ||
|
|
||
| use crate::errors::ChatError; | ||
|
|
||
| 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) | ||
| } | ||
|
|
||
| /// Supplies remote participants with the required keys to use Inbox protocol | ||
| pub struct Introduction { | ||
| pub installation_key: PublicKey, | ||
| pub ephemeral_key: PublicKey, | ||
| installation_key: PublicKey, | ||
| ephemeral_key: PublicKey, | ||
| signature: Ed25519Signature, | ||
| } | ||
|
|
||
| impl From<PrekeyBundle> for Introduction { | ||
| fn from(value: PrekeyBundle) -> Self { | ||
| Introduction { | ||
| installation_key: value.identity_key, | ||
| ephemeral_key: value.signed_prekey, | ||
| 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, | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
|
|
||
| impl From<Introduction> for Vec<u8> { | ||
| fn from(val: Introduction) -> Self { | ||
| // TODO: avoid copies, via writing directly to slice | ||
| let link = format!( | ||
| "Bundle:{}:{}", | ||
| hex::encode(val.installation_key.as_bytes()), | ||
| hex::encode(val.ephemeral_key.as_bytes()), | ||
| ); | ||
| 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); | ||
|
|
||
| link.into_bytes() | ||
| result.into_bytes() | ||
| } | ||
| } | ||
|
|
||
| impl TryFrom<&[u8]> for Introduction { | ||
| type Error = ChatError; | ||
|
|
||
| fn try_from(value: &[u8]) -> Result<Self, Self::Error> { | ||
| let str_value = String::from_utf8_lossy(value); | ||
| let parts: Vec<&str> = str_value.splitn(3, ':').collect(); | ||
| let str_value = std::str::from_utf8(value) | ||
| .map_err(|_| ChatError::BadBundleValue("invalid UTF-8".into()))?; | ||
|
|
||
| if parts[0] != "Bundle" { | ||
| return Err(ChatError::BadBundleValue( | ||
| "not recognized as an introduction bundle".into(), | ||
| )); | ||
| } | ||
| 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()))?; | ||
|
|
||
| let installation_bytes: [u8; 32] = hex::decode(parts[1]) | ||
| .map_err(|_| ChatError::BadParsing("installation_key"))? | ||
| let installation_bytes: [u8; 32] = bundle | ||
| .installation_pubkey | ||
| .as_ref() | ||
| .try_into() | ||
| .map_err(|_| ChatError::InvalidKeyLength)?; | ||
| let installation_key = PublicKey::from(installation_bytes); | ||
|
|
||
| let ephemeral_bytes: [u8; 32] = hex::decode(parts[2]) | ||
| .map_err(|_| ChatError::BadParsing("ephemeral_key"))? | ||
| let ephemeral_bytes: [u8; 32] = bundle | ||
| .ephemeral_pubkey | ||
| .as_ref() | ||
| .try_into() | ||
| .map_err(|_| ChatError::InvalidKeyLength)?; | ||
|
|
||
| let signature_bytes: [u8; 64] = bundle | ||
| .signature | ||
| .as_ref() | ||
| .try_into() | ||
| .map_err(|_| ChatError::BadBundleValue("invalid signature length".into()))?; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dust] From a defensive code perspective. I'd consider validating the signature here. Its possible for this type to create an invariant that That would contain the validation logic within this file and make it harder for future contributors to make mistakes.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point, adapted the code 👍 |
||
|
|
||
| let installation_key = PublicKey::from(installation_bytes); | ||
| let ephemeral_key = PublicKey::from(ephemeral_bytes); | ||
| let signature = Ed25519Signature(signature_bytes); | ||
|
|
||
| verify_intro_binding(&installation_key, &ephemeral_key, &signature) | ||
| .map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?; | ||
|
|
||
| Ok(Introduction { | ||
| installation_key, | ||
| ephemeral_key, | ||
| signature, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| #[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()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| mod keys; | ||
| mod x3dh; | ||
| mod xeddsa_sign; | ||
|
|
||
| pub use keys::{GenericArray, SecretKey}; | ||
| pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake}; | ||
| pub use xeddsa_sign::{Ed25519Signature, SignatureError, xeddsa_sign, xeddsa_verify}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch