[WIP] 0.3.0 mtp update

This commit is contained in:
Alex Emmet 2026-08-18 22:39:02 +02:00
commit e1dd86ec02
No known key found for this signature in database
42 changed files with 2422 additions and 1429 deletions

View file

@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
iota-paths = { path = "../iota-paths" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
"crypto"
] }

View file

@ -1,5 +1,50 @@
use base64::{Engine as _, engine::general_purpose::STANDARD};
use mtp::crypto::{EncryptionType, Keyring, PublicKeyBundle, decrypt_with, encrypt_for};
use mtp::crypto::{
EncryptionType, Keyring, MultiEncryptedMessage, PublicKeyBundle, decrypt_multi_for,
encrypt_multi_for,
};
const CHALLENGE_PURPOSE: u8 = 0x01;
const LEGACY_AAD_DOMAIN: &[u8] = b"IOTA-MTP-AAD-1";
fn bind_aad(plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
let aad_len = u32::try_from(aad.len())
.map_err(|_| "associated data is too large to encode".to_string())?;
let mut bound = Vec::with_capacity(
LEGACY_AAD_DOMAIN
.len()
.saturating_add(4)
.saturating_add(aad.len())
.saturating_add(plaintext.len()),
);
bound.extend_from_slice(LEGACY_AAD_DOMAIN);
bound.extend_from_slice(&aad_len.to_be_bytes());
bound.extend_from_slice(aad);
bound.extend_from_slice(plaintext);
Ok(bound)
}
fn unbind_aad(bound: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
let header_len = LEGACY_AAD_DOMAIN.len() + 4;
if bound.len() < header_len || &bound[..LEGACY_AAD_DOMAIN.len()] != LEGACY_AAD_DOMAIN {
return Err("associated-data binding is invalid".to_string());
}
let length_start = LEGACY_AAD_DOMAIN.len();
let length_end = length_start + 4;
let aad_len = u32::from_be_bytes(
bound[length_start..length_end]
.try_into()
.map_err(|_| "associated-data length is invalid".to_string())?,
) as usize;
let aad_start = length_end;
let aad_end = aad_start
.checked_add(aad_len)
.ok_or_else(|| "associated-data length overflows".to_string())?;
if aad_end > bound.len() || &bound[aad_start..aad_end] != aad {
return Err("associated data does not match".to_string());
}
Ok(bound[aad_end..].to_vec())
}
#[derive(Clone, Copy, Debug)]
pub enum DataFormat {
@ -13,17 +58,25 @@ pub fn encrypt(
aad: &[u8],
recipient_pub_key_bundle: &PublicKeyBundle,
) -> Result<Vec<u8>, String> {
encrypt_for(
let bound_plaintext = bind_aad(plaintext, aad)?;
let encrypted = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
recipient_pub_key_bundle,
plaintext,
aad,
CHALLENGE_PURPOSE,
&bound_plaintext,
std::slice::from_ref(recipient_pub_key_bundle),
)
.map_err(|e| format!("encryption error: {:?}", e))
.map_err(|e| format!("encryption error: {e:?}"))?;
encrypted
.to_bytes()
.map_err(|e| format!("encryption encoding error: {e:?}"))
}
pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result<Vec<u8>, String> {
decrypt_with(ciphertext, keyring, aad).map_err(|e| format!("decryption error: {:?}", e))
let message = MultiEncryptedMessage::from_bytes(ciphertext)
.map_err(|e| format!("decryption envelope error: {e:?}"))?;
let bound_plaintext = decrypt_multi_for(&message, CHALLENGE_PURPOSE, keyring)
.map_err(|e| format!("decryption error: {e:?}"))?;
unbind_aad(&bound_plaintext, aad)
}
pub fn encrypt_challenge(
@ -51,3 +104,18 @@ pub fn export(data: &[u8], format: DataFormat) -> Result<String, String> {
DataFormat::Hex => Ok(hex::encode(data)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_binds_associated_data() -> Result<(), String> {
let keyring = Keyring::generate();
let ciphertext = encrypt(b"challenge", b"context", &keyring.public_key_bundle())?;
assert_eq!(decrypt(&ciphertext, b"context", &keyring)?, b"challenge");
assert!(decrypt(&ciphertext, b"other-context", &keyring).is_err());
Ok(())
}
}

View file

@ -44,7 +44,9 @@ pub fn delete_user_directory(user_id: i64) -> io::Result<()> {
}
pub fn credential_path(user_id: i64) -> PathBuf {
storage_directory().join("credentials").join(format!("{user_id}.tu"))
storage_directory()
.join("credentials")
.join(format!("{user_id}.tu"))
}
pub fn read_user_credential(user_id: i64) -> io::Result<Option<String>> {
@ -58,7 +60,10 @@ pub fn read_user_credential(user_id: i64) -> io::Result<Option<String>> {
/// Resolve a credential by immutable account id. A valid legacy
/// `<username>.tu` is migrated atomically the first time it is encountered.
pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result<Option<String>> {
pub fn read_user_credential_with_legacy(
user_id: i64,
username: &str,
) -> io::Result<Option<String>> {
if let Some(credential) = read_user_credential(user_id)? {
return Ok(Some(credential));
}
@ -71,7 +76,10 @@ pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Res
let parsed = crate::tu::TuCredential::parse(&credential)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
if parsed.user_id != user_id {
return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"legacy credential user id mismatch",
));
}
write_user_credential(user_id, &parsed.to_canonical_string())?;
fs::remove_file(legacy)?;

View file

@ -1,4 +1,6 @@
pub mod crypto_helper;
pub mod crypto_util;
pub mod file_util;
pub mod mtp_compat;
pub mod route_target;
pub mod tu;

View file

@ -0,0 +1,67 @@
use mtp::codec::{CommunicationValue, DataValue};
use mtp::type_map::DataTypeId;
/*
* Keep legacy control-plane handlers source-compatible while they migrate to
* MTP's explicit optional routing fields. Relay handlers must use sender() and
* receiver() directly so an absent outer sender cannot become an identity.
*/
pub trait CommunicationValueCompat {
fn get_id(&self) -> u32;
fn get_sender(&self) -> u64;
fn get_receiver(&self) -> u64;
}
impl CommunicationValueCompat for CommunicationValue {
fn get_id(&self) -> u32 {
self.id().unwrap_or_default()
}
fn get_sender(&self) -> u64 {
self.sender().unwrap_or_default()
}
fn get_receiver(&self) -> u64 {
self.receiver().unwrap_or_default()
}
}
pub trait OptionalDataValueExt<'a> {
fn as_bool(self) -> Option<bool>;
fn as_str(self) -> Option<&'a str>;
fn as_string(self) -> Option<String>;
fn as_number(self) -> Option<i128>;
fn as_signed_number(self) -> Option<i128>;
fn as_array(self) -> Option<Vec<DataValue>>;
fn as_container(self) -> Option<Vec<(DataTypeId, DataValue)>>;
}
impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> {
fn as_bool(self) -> Option<bool> {
self.and_then(DataValue::as_bool)
}
fn as_str(self) -> Option<&'a str> {
self.and_then(DataValue::as_str)
}
fn as_string(self) -> Option<String> {
self.and_then(DataValue::as_string)
}
fn as_number(self) -> Option<i128> {
self.and_then(DataValue::as_number)
}
fn as_signed_number(self) -> Option<i128> {
self.and_then(DataValue::as_signed_number)
}
fn as_array(self) -> Option<Vec<DataValue>> {
self.and_then(DataValue::as_array)
}
fn as_container(self) -> Option<Vec<(DataTypeId, DataValue)>> {
self.and_then(DataValue::as_container)
}
}

View file

@ -0,0 +1,44 @@
const TARGET_KIND_MASK: u64 = 0xC000_0000_0000_0000;
const TARGET_ID_MASK: u64 = (1_u64 << 48) - 1;
const USER_TARGET_KIND: u64 = 0x4000_0000_0000_0000;
const IOTA_TARGET_KIND: u64 = 0x8000_0000_0000_0000;
/*
* Relay receivers carry their namespace in the wire identity. This prevents
* a user ID and an Iota ID with the same numeric value from selecting the
* wrong connection at an Omikron.
*/
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteTarget {
User(u64),
Iota(u64),
}
impl RouteTarget {
pub fn wire_id(self) -> Option<u64> {
let (kind, id) = match self {
Self::User(id) => (USER_TARGET_KIND, id),
Self::Iota(id) => (IOTA_TARGET_KIND, id),
};
(id > 0 && id <= TARGET_ID_MASK).then_some(kind | id)
}
pub fn from_wire_id(value: u64) -> Option<Self> {
let id = value & TARGET_ID_MASK;
if id == 0 || value & !(TARGET_KIND_MASK | TARGET_ID_MASK) != 0 {
return None;
}
match value & TARGET_KIND_MASK {
USER_TARGET_KIND => Some(Self::User(id)),
IOTA_TARGET_KIND => Some(Self::Iota(id)),
_ => None,
}
}
pub const fn id(self) -> u64 {
match self {
Self::User(id) | Self::Iota(id) => id,
}
}
}

View file

@ -47,7 +47,10 @@ impl fmt::Debug for TuCredential {
impl TuCredential {
pub fn parse(input: &str) -> Result<Self, TuError> {
let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?;
let (identity, encoded_keyring) = input
.trim()
.split_once("::")
.ok_or(TuError::InvalidFormat)?;
if encoded_keyring.is_empty() || encoded_keyring.contains("::") {
return Err(TuError::InvalidFormat);
}
@ -60,7 +63,11 @@ impl TuCredential {
return Err(TuError::InvalidUserId);
}
let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?;
Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring })
Ok(Self {
user_id,
omega_host: omega_host.trim().to_owned(),
keyring,
})
}
pub fn public_key_bundle(&self) -> PublicKeyBundle {
@ -68,7 +75,12 @@ impl TuCredential {
}
pub fn to_canonical_string(&self) -> String {
format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring))
format!(
"{}@{}::{}",
self.user_id,
self.omega_host,
keyring_to_base64(&self.keyring)
)
}
}
@ -79,16 +91,31 @@ mod tests {
#[test]
fn round_trip_is_canonical() {
let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() };
let credential = TuCredential {
user_id: 42,
omega_host: "omega.example:443".into(),
keyring: generate_keyring(),
};
let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap();
assert_eq!(parsed.user_id, 42);
assert_eq!(parsed.omega_host, "omega.example:443");
assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string());
assert_eq!(
parsed.to_canonical_string(),
credential.to_canonical_string()
);
}
#[test]
fn rejects_malformed_credentials() {
for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] {
for value in [
"",
"1@omega",
"@omega::abc",
"0@omega::abc",
"281474976710656@omega::abc",
"1@::abc",
"1@omega::abc::def",
] {
assert!(TuCredential::parse(value).is_err(), "{value}");
}
}