lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 36ef244fceea0e6a446b3b98bb6d0e0692b633d8
parent 1611e13de71d0a47717dd07153459bab68bb81e2
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 06:10:08 +0000

simplex: add short link data crypto

Diffstat:
MCargo.lock | 22+++++++++++++++++++++-
MCargo.toml | 1+
Mcrates/simplex_smp_crypto/Cargo.toml | 1+
Mcrates/simplex_smp_crypto/src/error.rs | 16++++++++++++++++
Mcrates/simplex_smp_crypto/src/lib.rs | 14++++++++++++++
Acrates/simplex_smp_crypto/src/short_link.rs | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 400 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2704,6 +2704,15 @@ dependencies = [ [[package]] name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" @@ -4646,6 +4655,7 @@ dependencies = [ "radroots_simplex_smp_proto", "salsa20", "sha2", + "sha3", "sntrup761", "subtle", "x25519-dalek", @@ -5559,13 +5569,23 @@ dependencies = [ ] [[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] name = "shake" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", "sponge-cursor", ] diff --git a/Cargo.toml b/Cargo.toml @@ -140,6 +140,7 @@ serde = { version = "1", default-features = false, features = [ ] } serde_json = { version = "1", default-features = false, features = ["alloc"] } sha2 = { version = "0.10", default-features = false } +sha3 = { version = "0.10", default-features = false } sntrup761 = { version = "0.4.0", default-features = false, features = [ "dcap", "ecap", diff --git a/crates/simplex_smp_crypto/Cargo.toml b/crates/simplex_smp_crypto/Cargo.toml @@ -37,6 +37,7 @@ poly1305 = { workspace = true, default-features = false } radroots_simplex_smp_proto = { workspace = true, default-features = false } salsa20 = { workspace = true, default-features = false } sha2 = { workspace = true, default-features = false } +sha3 = { workspace = true, default-features = false } subtle = { workspace = true, default-features = false } sntrup761 = { workspace = true, default-features = false, features = [ "dcap", diff --git a/crates/simplex_smp_crypto/src/error.rs b/crates/simplex_smp_crypto/src/error.rs @@ -22,6 +22,10 @@ pub enum RadrootsSimplexSmpCryptoError { InvalidSessionIdentifier(String), InvalidKeyDerivationLength(usize), InvalidSecretBoxChainKeyLength(usize), + InvalidShortLinkIdLength(usize), + InvalidShortLinkKeyLength(usize), + InvalidShortLinkDataLength { field: &'static str, length: usize }, + ShortLinkDataHashMismatch, AesGcmAuthenticationFailed, InvalidOfficialRatchetVersion(u16), InvalidOfficialRatchetPadding, @@ -101,6 +105,18 @@ impl fmt::Display for RadrootsSimplexSmpCryptoError { Self::InvalidSecretBoxChainKeyLength(length) => { write!(f, "invalid SMP secretbox chain key length {length}") } + Self::InvalidShortLinkIdLength(length) => { + write!(f, "invalid SMP short-link id length {length}") + } + Self::InvalidShortLinkKeyLength(length) => { + write!(f, "invalid SMP short-link key length {length}") + } + Self::InvalidShortLinkDataLength { field, length } => { + write!(f, "invalid SMP short-link data `{field}` length {length}") + } + Self::ShortLinkDataHashMismatch => { + write!(f, "SMP short-link data hash mismatch") + } Self::AesGcmAuthenticationFailed => { write!(f, "failed to authenticate SMP AES-GCM payload") } diff --git a/crates/simplex_smp_crypto/src/lib.rs b/crates/simplex_smp_crypto/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod message; pub mod official_ratchet; pub mod ratchet; +pub mod short_link; pub mod prelude { pub use crate::auth::{ @@ -61,4 +62,17 @@ pub mod prelude { RadrootsSimplexSmpRatchetHeader, RadrootsSimplexSmpRatchetRole, RadrootsSimplexSmpRatchetState, RadrootsSimplexSmpSkippedMessageKey, }; + pub use crate::short_link::{ + RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_INFO, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_KDF_OUTPUT_LENGTH, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_FIXED_DATA_PADDED_LENGTH, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_ID_LENGTH, RADROOTS_SIMPLEX_SMP_SHORT_LINK_INVITATION_INFO, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_USER_DATA_PADDED_LENGTH, + RadrootsSimplexSmpContactShortLinkKeyMaterial, RadrootsSimplexSmpVerifiedShortLinkData, + decrypt_short_link_data, decrypt_verify_short_link_data, + derive_contact_short_link_key_material, derive_invitation_short_link_data_key, + encrypt_short_link_data, encrypt_short_link_data_with_nonces, sign_short_link_data, + verify_signed_short_link_data, + }; } diff --git a/crates/simplex_smp_crypto/src/short_link.rs b/crates/simplex_smp_crypto/src/short_link.rs @@ -0,0 +1,347 @@ +use crate::auth::{RadrootsSimplexSmpEd25519Keypair, verify_signature}; +use crate::error::RadrootsSimplexSmpCryptoError; +use crate::message::{ + RADROOTS_SIMPLEX_SMP_NONCE_LENGTH, RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH, decrypt_padded, + encrypt_padded, random_nonce, +}; +use alloc::vec; +use alloc::vec::Vec; +use ed25519_dalek::Signer; +use hkdf::Hkdf; +use radroots_simplex_smp_proto::prelude::RadrootsSimplexSmpQueueLinkData; +use sha2::Sha512; +use sha3::{Digest, Sha3_256}; + +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_ID_LENGTH: usize = 24; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH: usize = 32; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_KDF_OUTPUT_LENGTH: usize = 56; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_FIXED_DATA_PADDED_LENGTH: usize = 2008; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_USER_DATA_PADDED_LENGTH: usize = 13784; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_INFO: &[u8] = b"SimpleXContactLink"; +pub const RADROOTS_SIMPLEX_SMP_SHORT_LINK_INVITATION_INFO: &[u8] = b"SimpleXInvLink"; +const RADROOTS_SIMPLEX_SMP_SHORT_LINK_SIGNATURE_LENGTH: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpContactShortLinkKeyMaterial { + pub link_id: Vec<u8>, + pub link_data_key: Vec<u8>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexSmpVerifiedShortLinkData { + pub fixed_data: Vec<u8>, + pub user_data: Vec<u8>, +} + +pub fn derive_invitation_short_link_data_key( + link_key: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + validate_link_key(link_key)?; + hkdf_expand( + link_key, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_INVITATION_INFO, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH, + ) +} + +pub fn derive_contact_short_link_key_material( + link_key: &[u8], +) -> Result<RadrootsSimplexSmpContactShortLinkKeyMaterial, RadrootsSimplexSmpCryptoError> { + validate_link_key(link_key)?; + let output = hkdf_expand( + link_key, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_INFO, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_CONTACT_KDF_OUTPUT_LENGTH, + )?; + let (link_id, link_data_key) = output.split_at(RADROOTS_SIMPLEX_SMP_SHORT_LINK_ID_LENGTH); + Ok(RadrootsSimplexSmpContactShortLinkKeyMaterial { + link_id: link_id.to_vec(), + link_data_key: link_data_key.to_vec(), + }) +} + +pub fn sign_short_link_data( + root_keypair: &RadrootsSimplexSmpEd25519Keypair, + fixed_data: &[u8], + user_data: &[u8], +) -> Result<(Vec<u8>, RadrootsSimplexSmpQueueLinkData), RadrootsSimplexSmpCryptoError> { + let link_key = short_link_data_hash(fixed_data); + let signing_key = root_keypair.signing_key()?; + Ok(( + link_key, + RadrootsSimplexSmpQueueLinkData { + fixed_data: sign_payload(&signing_key, fixed_data), + user_data: sign_payload(&signing_key, user_data), + }, + )) +} + +pub fn encrypt_short_link_data( + link_data_key: &[u8], + signed_data: &RadrootsSimplexSmpQueueLinkData, +) -> Result<RadrootsSimplexSmpQueueLinkData, RadrootsSimplexSmpCryptoError> { + let fixed_nonce = random_nonce()?; + let user_nonce = random_nonce()?; + encrypt_short_link_data_with_nonces(link_data_key, signed_data, &fixed_nonce, &user_nonce) +} + +pub fn encrypt_short_link_data_with_nonces( + link_data_key: &[u8], + signed_data: &RadrootsSimplexSmpQueueLinkData, + fixed_nonce: &[u8], + user_nonce: &[u8], +) -> Result<RadrootsSimplexSmpQueueLinkData, RadrootsSimplexSmpCryptoError> { + validate_link_key(link_data_key)?; + Ok(RadrootsSimplexSmpQueueLinkData { + fixed_data: encrypt_link_data_part( + link_data_key, + fixed_nonce, + &signed_data.fixed_data, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_FIXED_DATA_PADDED_LENGTH, + )?, + user_data: encrypt_link_data_part( + link_data_key, + user_nonce, + &signed_data.user_data, + RADROOTS_SIMPLEX_SMP_SHORT_LINK_USER_DATA_PADDED_LENGTH, + )?, + }) +} + +pub fn decrypt_short_link_data( + link_data_key: &[u8], + encrypted_data: &RadrootsSimplexSmpQueueLinkData, +) -> Result<RadrootsSimplexSmpQueueLinkData, RadrootsSimplexSmpCryptoError> { + validate_link_key(link_data_key)?; + Ok(RadrootsSimplexSmpQueueLinkData { + fixed_data: decrypt_link_data_part( + "fixed_data", + link_data_key, + &encrypted_data.fixed_data, + )?, + user_data: decrypt_link_data_part("user_data", link_data_key, &encrypted_data.user_data)?, + }) +} + +pub fn verify_signed_short_link_data( + link_key: &[u8], + root_public_key: &[u8], + signed_data: &RadrootsSimplexSmpQueueLinkData, +) -> Result<RadrootsSimplexSmpVerifiedShortLinkData, RadrootsSimplexSmpCryptoError> { + validate_link_key(link_key)?; + let fixed = split_signed_payload("fixed_data", &signed_data.fixed_data)?; + let user = split_signed_payload("user_data", &signed_data.user_data)?; + + if short_link_data_hash(fixed.payload).as_slice() != link_key { + return Err(RadrootsSimplexSmpCryptoError::ShortLinkDataHashMismatch); + } + verify_signature(fixed.payload, root_public_key, fixed.signature)?; + verify_signature(user.payload, root_public_key, user.signature)?; + Ok(RadrootsSimplexSmpVerifiedShortLinkData { + fixed_data: fixed.payload.to_vec(), + user_data: user.payload.to_vec(), + }) +} + +pub fn decrypt_verify_short_link_data( + link_key: &[u8], + link_data_key: &[u8], + root_public_key: &[u8], + encrypted_data: &RadrootsSimplexSmpQueueLinkData, +) -> Result<RadrootsSimplexSmpVerifiedShortLinkData, RadrootsSimplexSmpCryptoError> { + let signed_data = decrypt_short_link_data(link_data_key, encrypted_data)?; + verify_signed_short_link_data(link_key, root_public_key, &signed_data) +} + +fn short_link_data_hash(data: &[u8]) -> Vec<u8> { + Sha3_256::digest(data).to_vec() +} + +fn sign_payload(signing_key: &ed25519_dalek::SigningKey, payload: &[u8]) -> Vec<u8> { + let signature = signing_key.sign(payload); + let mut signed = + Vec::with_capacity(RADROOTS_SIMPLEX_SMP_SHORT_LINK_SIGNATURE_LENGTH + payload.len()); + signed.extend_from_slice(&signature.to_bytes()); + signed.extend_from_slice(payload); + signed +} + +fn encrypt_link_data_part( + link_data_key: &[u8], + nonce: &[u8], + data: &[u8], + padded_len: usize, +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let mut encrypted = Vec::with_capacity(RADROOTS_SIMPLEX_SMP_NONCE_LENGTH + 16 + padded_len); + let nonce_bytes: [u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH] = nonce + .try_into() + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidNonceLength(nonce.len()))?; + encrypted.extend_from_slice(&nonce_bytes); + encrypted.extend_from_slice(&encrypt_padded( + link_data_key, + &nonce_bytes, + data, + padded_len, + )?); + Ok(encrypted) +} + +fn decrypt_link_data_part( + field: &'static str, + link_data_key: &[u8], + data: &[u8], +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + if data.len() <= RADROOTS_SIMPLEX_SMP_NONCE_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidShortLinkDataLength { + field, + length: data.len(), + }); + } + let (nonce, ciphertext) = data.split_at(RADROOTS_SIMPLEX_SMP_NONCE_LENGTH); + decrypt_padded(link_data_key, nonce, ciphertext) +} + +struct SignedPayload<'a> { + signature: &'a [u8], + payload: &'a [u8], +} + +fn split_signed_payload<'a>( + field: &'static str, + data: &'a [u8], +) -> Result<SignedPayload<'a>, RadrootsSimplexSmpCryptoError> { + if data.len() <= RADROOTS_SIMPLEX_SMP_SHORT_LINK_SIGNATURE_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidShortLinkDataLength { + field, + length: data.len(), + }); + } + let (signature, payload) = data.split_at(RADROOTS_SIMPLEX_SMP_SHORT_LINK_SIGNATURE_LENGTH); + Ok(SignedPayload { signature, payload }) +} + +fn validate_link_key(link_key: &[u8]) -> Result<(), RadrootsSimplexSmpCryptoError> { + if link_key.len() != RADROOTS_SIMPLEX_SMP_SHARED_SECRET_LENGTH { + return Err(RadrootsSimplexSmpCryptoError::InvalidShortLinkKeyLength( + link_key.len(), + )); + } + Ok(()) +} + +fn hkdf_expand( + ikm: &[u8], + info: &[u8], + output_len: usize, +) -> Result<Vec<u8>, RadrootsSimplexSmpCryptoError> { + let hkdf = Hkdf::<Sha512>::new(Some(b""), ikm); + let mut output = vec![0_u8; output_len]; + hkdf.expand(info, &mut output) + .map_err(|_| RadrootsSimplexSmpCryptoError::InvalidKeyDerivationLength(output_len))?; + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + + fn keypair(seed: u8) -> RadrootsSimplexSmpEd25519Keypair { + let private_key = [seed; 32]; + let signing_key = SigningKey::from_bytes(&private_key); + RadrootsSimplexSmpEd25519Keypair { + public_key: signing_key.verifying_key().to_bytes().to_vec(), + private_key: private_key.to_vec(), + } + } + + #[test] + fn derives_invitation_and_contact_short_link_keys() { + let link_key = [7_u8; RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH]; + + let invitation = derive_invitation_short_link_data_key(&link_key).unwrap(); + let contact = derive_contact_short_link_key_material(&link_key).unwrap(); + + assert_eq!(invitation.len(), RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH); + assert_eq!( + contact.link_id.len(), + RADROOTS_SIMPLEX_SMP_SHORT_LINK_ID_LENGTH + ); + assert_eq!( + contact.link_data_key.len(), + RADROOTS_SIMPLEX_SMP_SHORT_LINK_KEY_LENGTH + ); + assert_ne!(invitation, contact.link_data_key); + } + + #[test] + fn signs_encrypts_decrypts_and_verifies_short_link_data() { + let root = keypair(11); + let fixed_data = b"rr-synth-fixed-link-data".to_vec(); + let user_data = b"rr-synth-user-link-data".to_vec(); + let (link_key, signed_data) = sign_short_link_data(&root, &fixed_data, &user_data).unwrap(); + let link_data_key = derive_invitation_short_link_data_key(&link_key).unwrap(); + let fixed_nonce = [1_u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]; + let user_nonce = [2_u8; RADROOTS_SIMPLEX_SMP_NONCE_LENGTH]; + + let encrypted = encrypt_short_link_data_with_nonces( + &link_data_key, + &signed_data, + &fixed_nonce, + &user_nonce, + ) + .unwrap(); + + assert_eq!( + encrypted.fixed_data.len(), + RADROOTS_SIMPLEX_SMP_NONCE_LENGTH + + 16 + + RADROOTS_SIMPLEX_SMP_SHORT_LINK_FIXED_DATA_PADDED_LENGTH + ); + assert_eq!( + encrypted.user_data.len(), + RADROOTS_SIMPLEX_SMP_NONCE_LENGTH + + 16 + + RADROOTS_SIMPLEX_SMP_SHORT_LINK_USER_DATA_PADDED_LENGTH + ); + let verified = + decrypt_verify_short_link_data(&link_key, &link_data_key, &root.public_key, &encrypted) + .unwrap(); + assert_eq!(verified.fixed_data, fixed_data); + assert_eq!(verified.user_data, user_data); + } + + #[test] + fn rejects_short_link_hash_mismatch() { + let root = keypair(13); + let (link_key, signed_data) = + sign_short_link_data(&root, b"rr-synth-fixed", b"rr-synth-user").unwrap(); + let mut wrong_link_key = link_key; + wrong_link_key[0] ^= 0xff; + + let error = verify_signed_short_link_data(&wrong_link_key, &root.public_key, &signed_data) + .unwrap_err(); + + assert!(matches!( + error, + RadrootsSimplexSmpCryptoError::ShortLinkDataHashMismatch + )); + } + + #[test] + fn rejects_tampered_signed_user_data() { + let root = keypair(17); + let (link_key, mut signed_data) = + sign_short_link_data(&root, b"rr-synth-fixed", b"rr-synth-user").unwrap(); + let last = signed_data.user_data.last_mut().unwrap(); + *last ^= 0xff; + + let error = + verify_signed_short_link_data(&link_key, &root.public_key, &signed_data).unwrap_err(); + + assert!(matches!( + error, + RadrootsSimplexSmpCryptoError::SignatureVerificationFailed + )); + } +}