1use std::{
8 fmt,
9 sync::{Arc, Mutex},
10};
11
12use lwuri::UriRef;
13use umsh_core::{AddressParseError, NodeHint, PublicKey, RouterHint};
14use umsh_crypto::{NodeIdentity, software::SoftwareIdentity};
15use umsh_uri::UmshUri;
16use zeroize::Zeroize;
17
18mod counter_store;
19mod mobile_chat;
20mod mobile_mesh;
21mod ulcp;
22
23pub use counter_store::{CounterStoreError, MobileCounterStore};
24pub use mobile_chat::{
25 MobileChatArchiveLookupRecord, MobileChatArchiveRecord, MobileChatArchiveResultKind,
26 MobileChatCheckpointRecord, MobileChatComposeBatchRecord, MobileChatDeliveryRecord,
27 MobileChatDeliveryState, MobileChatDirection, MobileChatMutationKind, MobileChatMutationRecord,
28 MobileChatOriginalRef,
29};
30pub use mobile_mesh::{
31 MobileMeshAdvertisementRecord, MobileMeshError, MobileMeshOutboundFrameRecord,
32 MobileMeshPeerHeardRecord, MobileMeshPingEventRecord, MobileMeshPingOutcome,
33 MobileMeshRouteKind, MobileMeshRouteRecord, MobileMeshRxRecord, MobileMeshSession,
34 MobileMeshSessionUpdateRecord,
35};
36pub use ulcp::{
37 GattSegmentRecord, MobileGattReassembler, MobileUlcpSession, UlcpAlertState, UlcpAttachMode,
38 UlcpBatteryRecord, UlcpChargeState, UlcpDeviceConfigRecord, UlcpFixKind, UlcpGnssRecord,
39 UlcpGnssSettingsRecord, UlcpHostOwnership, UlcpOperationErrorRecord, UlcpPropertyFrameRecord,
40 UlcpRadioSettingsRecord, UlcpReceivedFrameRecord, UlcpRepeaterSettingsRecord, UlcpSessionPhase,
41 UlcpSessionSnapshotRecord, UlcpSessionUpdateRecord, UlcpSyncRecord, UlcpTimeRecord,
42 inspect_ulcp_alert, inspect_ulcp_battery, inspect_ulcp_property_frame, inspect_ulcp_status,
43 inspect_ulcp_sync, region_code_description, region_code_from_string, ulcp_gatt_segments,
44 ulcp_inspection_properties, ulcp_location_cell_meters, ulcp_max_dev_channels,
45 ulcp_max_dev_peers, ulcp_prop_get, ulcp_prop_set, ulcp_save,
46};
47
48uniffi::setup_scaffolding!();
49
50pub const MOBILE_API_VERSION: u16 = 39;
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
58pub enum MobileError {
59 InvalidAddressLength,
60 InvalidAddressCharacter,
61 AddressOverflow,
62 InvalidNodeHintLength,
63 InvalidRouterHintLength,
64 InvalidSecretKeyLength,
65 InvalidPublicKeyLength,
66 InvalidUri,
67 InvalidIdentityData,
68 InvalidUlcpFrame,
69 InvalidGattSegment,
70 AdministrativeSession,
72 InvalidRegionCode,
75 UnsupportedCapability,
77 ChannelNameNotAscii,
80 ChannelNameTooLong,
82 InvalidChannelKeyLength,
84}
85
86impl MobileError {
87 pub const fn summary_key(self) -> &'static str {
89 match self {
90 Self::InvalidAddressLength => "mobile.error.address.invalid_length",
91 Self::InvalidAddressCharacter => "mobile.error.address.invalid_character",
92 Self::AddressOverflow => "mobile.error.address.overflow",
93 Self::InvalidNodeHintLength => "mobile.error.node_hint.invalid_length",
94 Self::InvalidRouterHintLength => "mobile.error.router_hint.invalid_length",
95 Self::InvalidSecretKeyLength => "mobile.error.secret_key.invalid_length",
96 Self::InvalidPublicKeyLength => "mobile.error.public_key.invalid_length",
97 Self::InvalidUri => "mobile.error.uri.invalid",
98 Self::InvalidIdentityData => "mobile.error.identity_data.invalid",
99 Self::InvalidUlcpFrame => "mobile.error.ulcp.invalid_frame",
100 Self::InvalidGattSegment => "mobile.error.ulcp.invalid_gatt_segment",
101 Self::AdministrativeSession => "mobile.error.ulcp.administrative_session",
102 Self::InvalidRegionCode => "mobile.error.region_code.invalid",
103 Self::UnsupportedCapability => "mobile.error.ulcp.unsupported_capability",
104 Self::ChannelNameNotAscii => "mobile.error.channel_name.not_ascii",
105 Self::ChannelNameTooLong => "mobile.error.channel_name.too_long",
106 Self::InvalidChannelKeyLength => "mobile.error.channel_key.invalid_length",
107 }
108 }
109
110 pub const fn diagnostic_code(self) -> &'static str {
112 match self {
113 Self::InvalidAddressLength => "ADDRESS_INVALID_LENGTH",
114 Self::InvalidAddressCharacter => "ADDRESS_INVALID_CHARACTER",
115 Self::AddressOverflow => "ADDRESS_OVERFLOW",
116 Self::InvalidNodeHintLength => "NODE_HINT_INVALID_LENGTH",
117 Self::InvalidRouterHintLength => "ROUTER_HINT_INVALID_LENGTH",
118 Self::InvalidSecretKeyLength => "SECRET_KEY_INVALID_LENGTH",
119 Self::InvalidPublicKeyLength => "PUBLIC_KEY_INVALID_LENGTH",
120 Self::InvalidUri => "URI_INVALID",
121 Self::InvalidIdentityData => "IDENTITY_DATA_INVALID",
122 Self::InvalidUlcpFrame => "ULCP_INVALID_FRAME",
123 Self::InvalidGattSegment => "ULCP_INVALID_GATT_SEGMENT",
124 Self::AdministrativeSession => "ULCP_ADMINISTRATIVE_SESSION",
125 Self::InvalidRegionCode => "REGION_CODE_INVALID",
126 Self::UnsupportedCapability => "ULCP_UNSUPPORTED_CAPABILITY",
127 Self::ChannelNameNotAscii => "CHANNEL_NAME_NOT_ASCII",
128 Self::ChannelNameTooLong => "CHANNEL_NAME_TOO_LONG",
129 Self::InvalidChannelKeyLength => "CHANNEL_KEY_INVALID_LENGTH",
130 }
131 }
132}
133
134impl fmt::Display for MobileError {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.write_str(self.diagnostic_code())
137 }
138}
139
140impl std::error::Error for MobileError {}
141
142impl From<AddressParseError> for MobileError {
143 fn from(value: AddressParseError) -> Self {
144 match value {
145 AddressParseError::InvalidLength => Self::InvalidAddressLength,
146 AddressParseError::InvalidCharacter => Self::InvalidAddressCharacter,
147 AddressParseError::Overflow => Self::AddressOverflow,
148 }
149 }
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
154pub struct NodeHintRecord {
155 pub bytes: Vec<u8>,
157 pub text: String,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
163pub struct RouterHintRecord {
164 pub bytes: Vec<u8>,
167 pub text: String,
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
173pub struct PublicIdentityRecord {
174 pub canonical_address: String,
176 pub hint: NodeHintRecord,
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
181pub enum IdentitySignatureState {
182 Unsigned,
184 Valid,
186 Invalid,
189}
190
191#[derive(Clone, Debug, PartialEq, uniffi::Record)]
193pub struct NodeIdentityRecord {
194 pub role_code: u8,
195 pub role_label: String,
197 pub capabilities: Vec<String>,
199 pub capability_bits: u8,
202 pub name: Option<String>,
203 pub latitude: Option<f64>,
205 pub longitude: Option<f64>,
206 pub location_precision: Option<u8>,
208 pub altitude_m: Option<i32>,
209 pub timestamp: Option<u32>,
211 pub signature: IdentitySignatureState,
212}
213
214#[derive(Clone, Debug, PartialEq, uniffi::Record)]
216pub struct NodeUriPreviewRecord {
217 pub canonical_address: String,
218 pub hint: NodeHintRecord,
219 pub has_identity_data: bool,
220 pub identity: Option<NodeIdentityRecord>,
222 pub identity_payload: Option<Vec<u8>>,
226}
227
228#[uniffi::export]
230pub fn inspect_node_uri(uri: String) -> Result<NodeUriPreviewRecord, MobileError> {
231 let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
232 let node = match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
233 UmshUri::Node(node) => node,
234 _ => return Err(MobileError::InvalidUri),
235 };
236 let identity = public_identity_record(&node.public_key);
237 let (decoded, payload) = match node.identity_data {
238 None => (None, None),
239 Some(data) => match umsh_uri::decode_base58_bytes(data).ok().and_then(|bytes| {
240 node_identity_record(&node.public_key, &bytes)
241 .ok()
242 .map(|record| (record, bytes))
243 }) {
244 None => (None, None),
245 Some((record, bytes)) => {
246 let payload =
248 (record.signature != IdentitySignatureState::Invalid).then_some(bytes);
249 (Some(record), payload)
250 }
251 },
252 };
253 Ok(NodeUriPreviewRecord {
254 canonical_address: identity.canonical_address,
255 hint: identity.hint,
256 has_identity_data: node.identity_data.is_some(),
257 identity: decoded,
258 identity_payload: payload,
259 })
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
264pub enum ChannelKindRecord {
265 NamedPublic,
268 PrivateKey,
270}
271
272#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
278pub struct ChannelPreviewRecord {
279 pub kind: ChannelKindRecord,
280 pub canonical_name: Option<String>,
284 pub key: Vec<u8>,
286 pub channel_id: Vec<u8>,
290 pub tint: Vec<u8>,
293 pub display_name: Option<String>,
295 pub max_flood_hops: Option<u8>,
297 pub region: Option<Vec<u8>>,
300}
301
302#[uniffi::export]
307pub fn inspect_channel_uri(uri: String) -> Result<ChannelPreviewRecord, MobileError> {
308 let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
309 match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
310 UmshUri::ChannelByName(parsed) => {
311 let name =
312 umsh_uri::decode_percent(parsed.name).map_err(|_| MobileError::InvalidUri)?;
313 let mut record = named_channel_preview(&name)?;
314 apply_channel_params(&mut record, &parsed.params)?;
315 Ok(record)
316 }
317 UmshUri::ChannelByKey(parsed) => {
318 let channel = umsh_node::Channel::private(parsed.key, "");
319 let mut record = ChannelPreviewRecord {
320 kind: ChannelKindRecord::PrivateKey,
321 canonical_name: None,
322 key: channel.key().0.to_vec(),
323 channel_id: channel.channel_id().0.to_vec(),
324 tint: channel_tint(channel.key()),
325 display_name: None,
326 max_flood_hops: None,
327 region: None,
328 };
329 apply_channel_params(&mut record, &parsed.params)?;
330 Ok(record)
331 }
332 UmshUri::Node(_) => Err(MobileError::InvalidUri),
333 }
334}
335
336#[uniffi::export]
341pub fn inspect_channel_name(name: String) -> Result<ChannelPreviewRecord, MobileError> {
342 named_channel_preview(&name)
343}
344
345#[uniffi::export]
350pub fn generate_channel_key() -> Vec<u8> {
351 use rand::Rng;
352
353 let mut key = [0u8; 32];
354 rand::rng().fill_bytes(&mut key);
355 let bytes = key.to_vec();
356 key.zeroize();
357 bytes
358}
359
360#[uniffi::export]
365pub fn derive_channel_id(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
366 let channel = umsh_node::Channel::private(channel_key_from_bytes(&key)?, "");
367 Ok(channel.channel_id().0.to_vec())
368}
369
370#[uniffi::export]
373pub fn derive_channel_tint(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
374 Ok(channel_tint(&channel_key_from_bytes(&key)?))
375}
376
377fn channel_tint(key: &umsh_core::ChannelKey) -> Vec<u8> {
378 use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
379
380 umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256)
381 .derive_channel_tint(key)
382 .to_vec()
383}
384
385pub(crate) fn channel_tag(key: &umsh_core::ChannelKey) -> umsh_core::ChannelTag {
391 use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
392
393 umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256).derive_channel_tag(key)
394}
395
396pub(crate) const EMERGENCY_CHANNEL_NAME: &str = "emergency";
398
399pub(crate) fn emergency_channel_tag() -> umsh_core::ChannelTag {
406 static TAG: std::sync::OnceLock<umsh_core::ChannelTag> = std::sync::OnceLock::new();
407
408 *TAG.get_or_init(|| {
409 channel_tag(
410 umsh_node::Channel::named(EMERGENCY_CHANNEL_NAME)
411 .expect("the emergency channel name is short ASCII")
412 .key(),
413 )
414 })
415}
416
417#[uniffi::export]
422pub fn channel_conversation_address(key: Vec<u8>) -> Result<String, MobileError> {
423 Ok(crate::mobile_chat::channel_address(channel_tag(
424 &channel_key_from_bytes(&key)?,
425 )))
426}
427
428#[uniffi::export]
434pub fn format_channel_invitation(
435 key: Vec<u8>,
436 name: Option<String>,
437 display_name: Option<String>,
438 max_flood_hops: Option<u8>,
439 region: Option<Vec<u8>>,
440) -> Result<String, MobileError> {
441 let region_text = match region {
442 None => None,
443 Some(code) => Some(ulcp::region_code_description(code)?),
444 };
445 let params = umsh_uri::ChannelParams {
446 display_name: display_name.as_deref(),
447 max_flood_hops,
448 region: region_text.as_deref(),
449 raw_query: None,
450 };
451
452 let mut buf = alloc_uri_buffer(¶ms);
455 let written = match name.as_deref() {
456 Some(name) => umsh_uri::format_channel_name_uri_with_params(name, ¶ms, &mut buf),
457 None => umsh_uri::format_channel_key_uri_with_params(
458 &channel_key_from_bytes(&key)?,
459 ¶ms,
460 &mut buf,
461 ),
462 }
463 .map_err(|_| MobileError::InvalidUri)?;
464
465 String::from_utf8(buf[..written].to_vec()).map_err(|_| MobileError::InvalidUri)
466}
467
468fn alloc_uri_buffer(params: &umsh_uri::ChannelParams<'_>) -> Vec<u8> {
469 let escaped = |value: Option<&str>| value.map_or(0, |text| text.len() * 3);
470 let len = 128 + escaped(params.display_name) + escaped(params.region);
471 vec![0u8; len]
472}
473
474fn named_channel_preview(name: &str) -> Result<ChannelPreviewRecord, MobileError> {
475 let channel = umsh_node::Channel::named(name).map_err(|err| match err {
476 umsh_crypto::ChannelNameError::NotAscii => MobileError::ChannelNameNotAscii,
477 umsh_crypto::ChannelNameError::TooLong => MobileError::ChannelNameTooLong,
478 })?;
479 Ok(ChannelPreviewRecord {
480 kind: ChannelKindRecord::NamedPublic,
481 canonical_name: Some(name.to_ascii_lowercase()),
482 key: channel.key().0.to_vec(),
483 channel_id: channel.channel_id().0.to_vec(),
484 tint: channel_tint(channel.key()),
485 display_name: Some(name.to_owned()),
489 max_flood_hops: None,
490 region: None,
491 })
492}
493
494fn apply_channel_params(
499 record: &mut ChannelPreviewRecord,
500 params: &umsh_uri::ChannelParams<'_>,
501) -> Result<(), MobileError> {
502 if let Some(display_name) = params.display_name {
503 record.display_name =
504 Some(umsh_uri::decode_percent(display_name).map_err(|_| MobileError::InvalidUri)?);
505 }
506 record.max_flood_hops = params.max_flood_hops;
507 if let Some(region) = params.region {
508 record.region = umsh_uri::decode_percent(region)
509 .ok()
510 .and_then(|text| ulcp::region_code_from_string(text).ok());
511 }
512 Ok(())
513}
514
515fn channel_key_from_bytes(key: &[u8]) -> Result<umsh_core::ChannelKey, MobileError> {
516 let bytes: [u8; 32] = key
517 .try_into()
518 .map_err(|_| MobileError::InvalidChannelKeyLength)?;
519 Ok(umsh_core::ChannelKey(bytes))
520}
521
522#[uniffi::export]
527pub fn decode_node_identity(
528 address: String,
529 payload: Vec<u8>,
530) -> Result<NodeIdentityRecord, MobileError> {
531 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
532 node_identity_record(&key, &payload)
533}
534
535fn node_identity_record(
536 key: &PublicKey,
537 payload: &[u8],
538) -> Result<NodeIdentityRecord, MobileError> {
539 let identity = umsh_node::NodeIdentityPayload::from_bytes(payload)
540 .map_err(|_| MobileError::InvalidIdentityData)?;
541
542 let signature = match identity.signature {
543 None => IdentitySignatureState::Unsigned,
544 Some(signature) => {
545 let signed = &payload[..payload.len() - 64];
547 if umsh_crypto::verify_ed25519_signature(key, signed, &signature) {
548 IdentitySignatureState::Valid
549 } else {
550 IdentitySignatureState::Invalid
551 }
552 }
553 };
554
555 let role_label = match identity.role {
556 umsh_node::NodeRole::Unspecified => "Unspecified".to_owned(),
557 umsh_node::NodeRole::Repeater => "Repeater".to_owned(),
558 umsh_node::NodeRole::Chat => "Chat".to_owned(),
559 umsh_node::NodeRole::Tracker => "Tracker".to_owned(),
560 umsh_node::NodeRole::Sensor => "Sensor".to_owned(),
561 umsh_node::NodeRole::Bridge => "Bridge".to_owned(),
562 umsh_node::NodeRole::ChatRoom => "Chat room".to_owned(),
563 umsh_node::NodeRole::TemporarySession => "Temporary session".to_owned(),
564 umsh_node::NodeRole::Unknown(code) => format!("Unknown ({code})"),
565 };
566
567 let capabilities = [
568 (umsh_node::NodeCapabilities::REPEATER, "Repeater"),
569 (umsh_node::NodeCapabilities::MOBILE, "Mobile"),
570 (umsh_node::NodeCapabilities::TEXT_MESSAGES, "Text messages"),
571 (umsh_node::NodeCapabilities::TELEMETRY, "Telemetry"),
572 (umsh_node::NodeCapabilities::CHAT_ROOM, "Chat room"),
573 (umsh_node::NodeCapabilities::COAP, "CoAP"),
574 ]
575 .into_iter()
576 .filter(|(bit, _)| identity.capabilities.contains(*bit))
577 .map(|(_, label)| label.to_owned())
578 .collect();
579 let capability_bits = identity.capabilities.bits();
580
581 let location = identity.location.filter(|loc| !loc.is_unspecified());
582 let (latitude, longitude, location_precision) = match location {
583 None => (None, None, None),
584 Some(location) => {
585 let (lat, lon) = location.center();
586 (
587 Some(f64::from(lat)),
588 Some(f64::from(lon)),
589 Some(location.precision()),
590 )
591 }
592 };
593
594 Ok(NodeIdentityRecord {
595 role_code: identity.role.as_byte(),
596 role_label,
597 capabilities,
598 capability_bits,
599 name: identity.name,
600 latitude,
601 longitude,
602 location_precision,
603 altitude_m: identity.altitude_m,
604 timestamp: identity.timestamp,
605 signature,
606 })
607}
608
609#[derive(Clone, Debug, PartialEq, uniffi::Record)]
615pub struct NodeIdentityProfileRecord {
616 pub role_code: u8,
617 pub capability_bits: u8,
618 pub name: Option<String>,
619 pub latitude: Option<f64>,
624 pub longitude: Option<f64>,
625 pub location_precision: Option<u8>,
627 pub altitude_m: Option<i32>,
628 pub timestamp: Option<u32>,
629}
630
631#[uniffi::export]
645pub async fn sign_node_identity_bundle(
646 mut secret_key: Vec<u8>,
647 profile: NodeIdentityProfileRecord,
648) -> Result<Vec<u8>, MobileError> {
649 let mut bytes: [u8; 32] = match secret_key.as_slice().try_into() {
650 Ok(bytes) => bytes,
651 Err(_) => {
652 secret_key.zeroize();
653 return Err(MobileError::InvalidSecretKeyLength);
654 }
655 };
656 secret_key.zeroize();
657 let signer = SoftwareIdentity::from_secret_bytes(&bytes);
658 bytes.zeroize();
659
660 let name = profile
661 .name
662 .map(|name| {
663 let mut end = name.len().min(24);
664 while !name.is_char_boundary(end) {
665 end -= 1;
666 }
667 name[..end].to_owned()
668 })
669 .filter(|name| !name.is_empty());
670
671 let location = match (
672 profile.latitude,
673 profile.longitude,
674 profile.location_precision,
675 ) {
676 (Some(latitude), Some(longitude), Some(precision)) => {
677 if !(1..=umsh_node::location::MAX_PRECISION).contains(&precision)
678 || !latitude.is_finite()
679 || latitude.abs() > 90.0
680 || !longitude.is_finite()
681 || longitude.abs() > 180.0
682 {
683 return Err(MobileError::InvalidIdentityData);
684 }
685 Some(umsh_node::location::NodeLocation::from_e7(
686 (latitude * 1e7).round() as i32,
687 (longitude * 1e7).round() as i32,
688 precision,
689 ))
690 }
691 _ => None,
692 };
693
694 let payload = umsh_node::NodeIdentityPayload {
695 role: umsh_node::NodeRole::from_byte(profile.role_code),
696 capabilities: umsh_node::NodeCapabilities::from_bits_truncate(profile.capability_bits),
697 name,
698 location,
699 altitude_m: profile.altitude_m,
700 timestamp: profile.timestamp,
701 supported_regions: None,
702 nonce: None,
703 signature: None,
704 };
705
706 let mut buf = [0u8; 192];
707 let len = payload
708 .encode_for_signing(&mut buf)
709 .map_err(|_| MobileError::InvalidIdentityData)?;
710 let signature = signer
711 .sign(&buf[..len])
712 .await
713 .map_err(|_| MobileError::InvalidIdentityData)?;
714 let mut bundle = buf[..len].to_vec();
715 bundle.extend_from_slice(&signature);
716 Ok(bundle)
717}
718
719#[uniffi::export]
725pub fn inspect_peer_identity(input: String) -> Result<NodeUriPreviewRecord, MobileError> {
726 let input = input.trim();
727 if input.starts_with("umsh:") {
728 return inspect_node_uri(input.to_owned());
729 }
730
731 let hex_input = input
732 .strip_prefix("0x")
733 .or_else(|| input.strip_prefix("0X"))
734 .unwrap_or(input);
735 let hex = hex_input
736 .chars()
737 .filter(|character| !character.is_ascii_whitespace() && !matches!(character, ':' | '-'))
738 .collect::<String>();
739 if hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
740 let mut public_key = [0u8; 32];
741 for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
742 let pair =
743 core::str::from_utf8(pair).map_err(|_| MobileError::InvalidAddressCharacter)?;
744 public_key[index] =
745 u8::from_str_radix(pair, 16).map_err(|_| MobileError::InvalidAddressCharacter)?;
746 }
747 let identity = public_identity_record(&PublicKey(public_key));
748 return Ok(NodeUriPreviewRecord {
749 canonical_address: identity.canonical_address,
750 hint: identity.hint,
751 has_identity_data: false,
752 identity: None,
753 identity_payload: None,
754 });
755 }
756
757 let identity = inspect_public_identity(input.to_owned())?;
758 Ok(NodeUriPreviewRecord {
759 canonical_address: identity.canonical_address,
760 hint: identity.hint,
761 has_identity_data: false,
762 identity: None,
763 identity_payload: None,
764 })
765}
766
767#[uniffi::export]
772pub fn node_uri_for_address(address: String) -> Result<String, MobileError> {
773 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
774 let mut buf = [0u8; 64];
775 let len = umsh_uri::format_node_uri(&key, &mut buf).map_err(|_| MobileError::InvalidUri)?;
776 Ok(core::str::from_utf8(&buf[..len])
777 .expect("node URI is ASCII")
778 .to_owned())
779}
780
781#[uniffi::export]
784pub fn node_uri_with_identity(
785 address: String,
786 identity_payload: Vec<u8>,
787) -> Result<String, MobileError> {
788 let base = node_uri_for_address(address)?;
789 Ok(format!(
790 "{base}:{}",
791 umsh_uri::encode_base58_bytes(&identity_payload)
792 ))
793}
794
795#[uniffi::export]
797pub fn mobile_api_version() -> u16 {
798 MOBILE_API_VERSION
799}
800
801#[uniffi::export]
803pub fn render_node_hint(bytes: Vec<u8>) -> Result<NodeHintRecord, MobileError> {
804 let bytes: [u8; 3] = bytes
805 .try_into()
806 .map_err(|_| MobileError::InvalidNodeHintLength)?;
807 Ok(render_node_hint_bytes(bytes))
808}
809
810#[uniffi::export]
812pub fn render_router_hint(bytes: Vec<u8>) -> Result<RouterHintRecord, MobileError> {
813 let bytes: [u8; 2] = bytes
814 .try_into()
815 .map_err(|_| MobileError::InvalidRouterHintLength)?;
816 Ok(RouterHintRecord {
817 bytes: bytes.to_vec(),
818 text: RouterHint(bytes).to_string(),
819 })
820}
821
822fn render_node_hint_bytes(bytes: [u8; 3]) -> NodeHintRecord {
823 NodeHintRecord {
824 bytes: bytes.to_vec(),
825 text: NodeHint(bytes).to_string(),
826 }
827}
828
829#[uniffi::export]
834pub fn inspect_public_identity(address: String) -> Result<PublicIdentityRecord, MobileError> {
835 let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
836 Ok(public_identity_record(&key))
837}
838
839#[uniffi::export]
842pub fn public_identity_bytes(address: String) -> Result<Vec<u8>, MobileError> {
843 Ok(umsh_core::base58::decode(address.as_bytes())?.to_vec())
844}
845
846#[uniffi::export]
849pub fn inspect_public_identity_bytes(
850 public_key: Vec<u8>,
851) -> Result<PublicIdentityRecord, MobileError> {
852 let bytes: [u8; 32] = public_key
853 .try_into()
854 .map_err(|_| MobileError::InvalidPublicKeyLength)?;
855 Ok(public_identity_record(&PublicKey(bytes)))
856}
857
858#[derive(uniffi::Object)]
861pub struct MobileIdentity {
862 identity: Mutex<Option<SoftwareIdentity>>,
863 public_identity: PublicIdentityRecord,
864}
865
866#[uniffi::export]
867impl MobileIdentity {
868 #[uniffi::constructor]
869 pub fn unlock(mut secret_key: Vec<u8>) -> Result<Arc<Self>, MobileError> {
870 let result = (|| {
871 let mut bytes: [u8; 32] = secret_key
872 .as_slice()
873 .try_into()
874 .map_err(|_| MobileError::InvalidSecretKeyLength)?;
875 let identity = SoftwareIdentity::from_secret_bytes(&bytes);
876 let public_identity = public_identity_record(identity.public_key());
877 bytes.zeroize();
878 Ok(Arc::new(Self {
879 identity: Mutex::new(Some(identity)),
880 public_identity,
881 }))
882 })();
883 secret_key.zeroize();
884 result
885 }
886
887 pub fn public_identity(&self) -> PublicIdentityRecord {
888 self.public_identity.clone()
889 }
890}
891
892impl MobileIdentity {
893 fn take_for_session(&self) -> Result<SoftwareIdentity, MobileMeshError> {
894 self.identity
895 .lock()
896 .map_err(|_| MobileMeshError::SessionUnavailable)?
897 .take()
898 .ok_or(MobileMeshError::SessionUnavailable)
899 }
900}
901
902fn public_identity_record(key: &PublicKey) -> PublicIdentityRecord {
903 let canonical_address = umsh_core::base58::encode(&key.0)
904 .into_iter()
905 .map(char::from)
906 .collect();
907
908 PublicIdentityRecord {
909 canonical_address,
910 hint: render_node_hint_bytes(NodeHint::from_public_key(&key).0),
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 #[tokio::test]
922 async fn signed_identity_bundle_round_trips_through_decoding() {
923 let secret = [0x42u8; 32];
924 let address = MobileIdentity::unlock(secret.to_vec())
925 .unwrap()
926 .public_identity()
927 .canonical_address;
928
929 let bundle = sign_node_identity_bundle(
930 secret.to_vec(),
931 NodeIdentityProfileRecord {
932 role_code: umsh_node::NodeRole::Repeater.as_byte(),
933 capability_bits: (umsh_node::NodeCapabilities::REPEATER
934 | umsh_node::NodeCapabilities::TEXT_MESSAGES)
935 .bits(),
936 name: Some("Ridge Repeater".to_owned()),
937 latitude: Some(37.7749),
938 longitude: Some(-122.4194),
939 location_precision: Some(5),
940 altitude_m: Some(1_204),
941 timestamp: Some(1_760_000_000),
942 },
943 )
944 .await
945 .unwrap();
946
947 let decoded = decode_node_identity(address, bundle).unwrap();
948 assert_eq!(decoded.signature, IdentitySignatureState::Valid);
949 assert_eq!(decoded.role_label, "Repeater");
950 assert_eq!(decoded.name.as_deref(), Some("Ridge Repeater"));
951 assert_eq!(decoded.altitude_m, Some(1_204));
952 assert_eq!(decoded.timestamp, Some(1_760_000_000));
953 assert_eq!(decoded.location_precision, Some(5));
954 assert!((decoded.latitude.unwrap() - 37.7749).abs() < 0.001);
957 assert!((decoded.longitude.unwrap() + 122.4194).abs() < 0.001);
958 }
959
960 #[tokio::test]
962 async fn signed_identity_bundle_does_not_verify_for_another_key() {
963 let bundle = sign_node_identity_bundle(
964 [0x42u8; 32].to_vec(),
965 NodeIdentityProfileRecord {
966 role_code: umsh_node::NodeRole::Tracker.as_byte(),
967 capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
968 name: Some("Tracker".to_owned()),
969 latitude: None,
970 longitude: None,
971 location_precision: None,
972 altitude_m: None,
973 timestamp: None,
974 },
975 )
976 .await
977 .unwrap();
978
979 let impostor = MobileIdentity::unlock([0x43u8; 32].to_vec())
980 .unwrap()
981 .public_identity()
982 .canonical_address;
983 assert_eq!(
984 decode_node_identity(impostor, bundle).unwrap().signature,
985 IdentitySignatureState::Invalid
986 );
987 }
988
989 #[tokio::test]
993 async fn signed_identity_bundle_rejects_out_of_range_location() {
994 let profile = NodeIdentityProfileRecord {
995 role_code: umsh_node::NodeRole::Tracker.as_byte(),
996 capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
997 name: None,
998 latitude: Some(37.0),
999 longitude: Some(-122.0),
1000 location_precision: Some(9),
1001 altitude_m: None,
1002 timestamp: None,
1003 };
1004 assert_eq!(
1005 sign_node_identity_bundle([0x42u8; 32].to_vec(), profile)
1006 .await
1007 .unwrap_err(),
1008 MobileError::InvalidIdentityData
1009 );
1010 }
1011
1012 #[test]
1013 fn reference_node_hints_match_protocol_vectors() {
1014 for (bytes, expected) in [
1015 ([0x00, 0x00, 0x00], "1111"),
1016 ([0xFF, 0xFF, 0xFF], "JEKN"),
1017 ([0xA1, 0xB2, 0x03], "BtC5"),
1018 ([0x84, 0x81, 0x1B], "9v*"),
1019 ] {
1020 assert_eq!(
1021 render_node_hint(bytes.to_vec()).unwrap(),
1022 NodeHintRecord {
1023 bytes: bytes.to_vec(),
1024 text: expected.to_owned(),
1025 }
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn reference_router_hints_match_protocol_vectors() {
1032 for (bytes, expected) in [
1033 ([0x00, 0x00], "111"),
1034 ([0xA1, 0xB2], "BtC"),
1035 ([0x5E, 0xA1], "7N*"),
1036 ([0x00, 0x41], "1*"),
1037 ] {
1038 assert_eq!(
1039 render_router_hint(bytes.to_vec()).unwrap(),
1040 RouterHintRecord {
1041 bytes: bytes.to_vec(),
1042 text: expected.to_owned(),
1043 }
1044 );
1045 }
1046
1047 assert_eq!(
1048 render_router_hint(vec![0x00, 0x01, 0x02]),
1049 Err(MobileError::InvalidRouterHintLength)
1050 );
1051 }
1052
1053 #[test]
1054 fn public_identity_is_canonical_and_derives_hint_in_rust() {
1055 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1056 let identity = inspect_public_identity(address.to_owned()).unwrap();
1057
1058 assert_eq!(identity.canonical_address, address);
1059 assert_eq!(identity.hint.bytes, [0, 1, 2]);
1060 assert_eq!(identity.hint.text, "111t");
1061 }
1062
1063 #[test]
1064 fn node_uri_preview_is_typed_and_non_mutating() {
1065 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1066 let preview = inspect_node_uri(format!("umsh:n:{address}")).unwrap();
1067 assert_eq!(preview.canonical_address, address);
1068 assert!(!preview.has_identity_data);
1069
1070 let with_metadata = inspect_node_uri(format!("umsh:n:{address}:signed-data")).unwrap();
1071 assert!(with_metadata.has_identity_data);
1072 assert_eq!(
1073 inspect_node_uri("umsh:cs:public".to_owned()),
1074 Err(MobileError::InvalidUri)
1075 );
1076 }
1077
1078 #[test]
1079 fn channel_and_node_uri_inspection_reject_each_other() {
1080 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1081 assert_eq!(
1082 inspect_channel_uri(format!("umsh:n:{address}")),
1083 Err(MobileError::InvalidUri)
1084 );
1085 assert!(inspect_channel_uri("umsh:cs:public".to_owned()).is_ok());
1086 }
1087
1088 #[test]
1089 fn named_channel_key_follows_the_canonical_lowercase_name() {
1090 let typed = inspect_channel_name("Public".to_owned()).unwrap();
1091 let canonical = inspect_channel_name("public".to_owned()).unwrap();
1092
1093 assert_eq!(typed.kind, ChannelKindRecord::NamedPublic);
1094 assert_eq!(typed.canonical_name.as_deref(), Some("public"));
1095 assert_eq!(typed.key, canonical.key);
1098 assert_eq!(typed.channel_id, canonical.channel_id);
1099 assert_eq!(typed.key.len(), 32);
1100 assert_eq!(typed.channel_id.len(), 2);
1101 }
1102
1103 #[test]
1104 fn named_channel_uri_and_plain_name_agree() {
1105 let from_uri = inspect_channel_uri("umsh:cs:EMERGENCY".to_owned()).unwrap();
1106 let from_name = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1107 assert_eq!(from_uri.key, from_name.key);
1108 assert_eq!(from_uri.canonical_name.as_deref(), Some("emergency"));
1109 }
1110
1111 #[test]
1112 fn channel_names_outside_the_derivation_domain_are_typed_errors() {
1113 assert_eq!(
1114 inspect_channel_name("café".to_owned()),
1115 Err(MobileError::ChannelNameNotAscii)
1116 );
1117 assert_eq!(
1118 inspect_channel_name("x".repeat(65)),
1119 Err(MobileError::ChannelNameTooLong)
1120 );
1121 }
1122
1123 #[test]
1124 fn private_channel_invitation_round_trips_through_a_uri() {
1125 let key = generate_channel_key();
1126 let region = ulcp::region_code_from_string("SJC".to_owned()).unwrap();
1127 let uri = format_channel_invitation(
1128 key.clone(),
1129 None,
1130 Some("Trail Crew".to_owned()),
1131 Some(3),
1132 Some(region.clone()),
1133 )
1134 .unwrap();
1135 assert!(uri.starts_with("umsh:ck:"));
1136
1137 let preview = inspect_channel_uri(uri).unwrap();
1138 assert_eq!(preview.kind, ChannelKindRecord::PrivateKey);
1139 assert_eq!(preview.key, key);
1140 assert_eq!(preview.canonical_name, None);
1141 assert_eq!(preview.display_name.as_deref(), Some("Trail Crew"));
1142 assert_eq!(preview.max_flood_hops, Some(3));
1143 assert_eq!(preview.region, Some(region));
1144 assert_eq!(preview.channel_id, derive_channel_id(key).unwrap());
1145 }
1146
1147 #[test]
1148 fn named_channel_invitation_keeps_the_name_out_of_the_key_slot() {
1149 let key = inspect_channel_name("trail-crew".to_owned()).unwrap().key;
1150 let uri =
1151 format_channel_invitation(key.clone(), Some("trail-crew".to_owned()), None, None, None)
1152 .unwrap();
1153 assert_eq!(uri, "umsh:cs:trail-crew");
1154
1155 let preview = inspect_channel_uri(uri).unwrap();
1156 assert_eq!(preview.kind, ChannelKindRecord::NamedPublic);
1157 assert_eq!(preview.key, key);
1158 }
1159
1160 #[test]
1161 fn a_named_channel_invitation_carries_the_name_as_written() {
1162 let key = inspect_channel_name("Trail Crew".to_owned()).unwrap().key;
1167 let uri =
1168 format_channel_invitation(key.clone(), Some("Trail Crew".to_owned()), None, None, None)
1169 .unwrap();
1170 assert_eq!(uri, "umsh:cs:Trail%20Crew");
1171
1172 let back = inspect_channel_uri(uri).unwrap();
1173 assert_eq!(back.key, key);
1174 assert_eq!(back.canonical_name.as_deref(), Some("trail crew"));
1175 assert_eq!(back.display_name.as_deref(), Some("Trail Crew"));
1177 }
1178
1179 #[test]
1180 fn a_named_channel_preview_keeps_the_written_casing_beside_the_canonical_form() {
1181 let preview = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1182 assert_eq!(preview.canonical_name.as_deref(), Some("emergency"));
1183 assert_eq!(preview.display_name.as_deref(), Some("EMERGENCY"));
1184 assert_eq!(
1186 preview.key,
1187 inspect_channel_name("emergency".to_owned()).unwrap().key
1188 );
1189 }
1190
1191 #[test]
1192 fn generated_channel_keys_are_distinct_and_full_length() {
1193 let first = generate_channel_key();
1194 let second = generate_channel_key();
1195 assert_eq!(first.len(), 32);
1196 assert_ne!(first, second);
1197 assert_ne!(first, vec![0u8; 32]);
1198 }
1199
1200 #[test]
1201 fn a_channels_tint_extends_its_identifier() {
1202 let preview = inspect_channel_name("Public".to_owned()).unwrap();
1203 assert_eq!(preview.tint.len(), 3);
1204 assert_eq!(&preview.tint[..2], &preview.channel_id[..]);
1207 assert_eq!(derive_channel_tint(preview.key).unwrap(), preview.tint);
1208 }
1209
1210 #[test]
1211 fn channel_id_derivation_rejects_a_wrong_length_key() {
1212 assert_eq!(
1213 derive_channel_id(vec![0u8; 31]),
1214 Err(MobileError::InvalidChannelKeyLength)
1215 );
1216 }
1217
1218 #[test]
1219 fn an_unparseable_region_recommendation_does_not_fail_the_import() {
1220 let address = umsh_uri::encode_channel_key_base58(&umsh_core::ChannelKey([0x21; 32]));
1223 let preview = inspect_channel_uri(format!("umsh:ck:{address}?n=Camp;r=0xZZZZ")).unwrap();
1224 assert_eq!(preview.display_name.as_deref(), Some("Camp"));
1225 assert_eq!(preview.region, None);
1226 }
1227
1228 #[test]
1229 fn a_region_named_rather_than_coded_survives_the_invitation_round_trip() {
1230 let key = generate_channel_key();
1233 let region = ulcp::region_code_from_string("Willamette Valley".to_owned()).unwrap();
1234 let uri = format_channel_invitation(key, None, None, None, Some(region.clone())).unwrap();
1235 assert_eq!(inspect_channel_uri(uri).unwrap().region, Some(region));
1236 }
1237
1238 #[tokio::test]
1239 async fn signed_identity_bundle_round_trips_through_uri_inspection() {
1240 let identity = SoftwareIdentity::from_secret_bytes(&[7u8; 32]);
1241 let payload = umsh_node::NodeIdentityPayload {
1242 role: umsh_node::NodeRole::Chat,
1243 capabilities: umsh_node::NodeCapabilities::TEXT_MESSAGES
1244 | umsh_node::NodeCapabilities::MOBILE,
1245 name: Some("Basecamp".into()),
1246 location: Some(umsh_node::location::NodeLocation::from_lat_lon(
1247 44.05, -123.09, 4,
1248 )),
1249 altitude_m: Some(72),
1250 timestamp: Some(1_760_000_000),
1251 supported_regions: None,
1252 nonce: None,
1253 signature: None,
1254 };
1255 let mut buf = [0u8; 256];
1256 let len = payload.encode_for_signing(&mut buf).unwrap();
1257 let signature = identity.sign(&buf[..len]).await.unwrap();
1258 buf[len..len + 64].copy_from_slice(&signature);
1259 let bundle = &buf[..len + 64];
1260
1261 let address = public_identity_record(identity.public_key()).canonical_address;
1262 let uri = format!("umsh:n:{address}:{}", umsh_uri::encode_base58_bytes(bundle));
1263 let preview = inspect_node_uri(uri).unwrap();
1264 assert!(preview.has_identity_data);
1265 assert_eq!(preview.identity_payload.as_deref(), Some(bundle));
1266 let record = preview.identity.unwrap();
1267 assert_eq!(record.signature, IdentitySignatureState::Valid);
1268 assert_eq!(record.role_label, "Chat");
1269 assert_eq!(record.name.as_deref(), Some("Basecamp"));
1270 assert_eq!(
1271 record.capabilities,
1272 vec!["Mobile".to_owned(), "Text messages".to_owned()]
1273 );
1274 assert_eq!(
1275 record.capability_bits,
1276 (umsh_node::NodeCapabilities::TEXT_MESSAGES | umsh_node::NodeCapabilities::MOBILE)
1277 .bits()
1278 );
1279 assert_eq!(record.altitude_m, Some(72));
1280 assert_eq!(record.timestamp, Some(1_760_000_000));
1281 assert_eq!(record.location_precision, Some(4));
1282 assert!((record.latitude.unwrap() - 44.05).abs() < 0.01);
1284 assert!((record.longitude.unwrap() + 123.09).abs() < 0.01);
1285
1286 let mut tampered = bundle.to_vec();
1289 tampered[0] ^= 0x01;
1290 let uri = format!(
1291 "umsh:n:{address}:{}",
1292 umsh_uri::encode_base58_bytes(&tampered)
1293 );
1294 let preview = inspect_node_uri(uri).unwrap();
1295 assert_eq!(
1296 preview.identity.unwrap().signature,
1297 IdentitySignatureState::Invalid
1298 );
1299 assert!(preview.identity_payload.is_none());
1300
1301 let unsigned_len = {
1304 let unsigned = umsh_node::NodeIdentityPayload {
1305 signature: None,
1306 ..payload.clone()
1307 };
1308 unsigned.encode(&mut buf).unwrap()
1309 };
1310 let uri = format!(
1311 "umsh:n:{address}:{}",
1312 umsh_uri::encode_base58_bytes(&buf[..unsigned_len])
1313 );
1314 let preview = inspect_node_uri(uri).unwrap();
1315 assert_eq!(
1316 preview.identity.unwrap().signature,
1317 IdentitySignatureState::Unsigned
1318 );
1319 assert!(preview.identity_payload.is_some());
1320 }
1321
1322 #[test]
1323 fn node_uri_round_trips_through_inspection() {
1324 let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1325 let uri = node_uri_for_address(address.to_owned()).unwrap();
1326
1327 assert_eq!(uri, format!("umsh:n:{address}"));
1328 let preview = inspect_node_uri(uri).unwrap();
1329 assert_eq!(preview.canonical_address, address);
1330 }
1331
1332 #[test]
1333 fn errors_are_stable_and_do_not_echo_input() {
1334 let error = inspect_public_identity("secret-ish-invalid-input".to_owned()).unwrap_err();
1335
1336 assert_eq!(error, MobileError::InvalidAddressLength);
1337 assert_eq!(error.summary_key(), "mobile.error.address.invalid_length");
1338 assert_eq!(error.to_string(), "ADDRESS_INVALID_LENGTH");
1339 assert!(!error.to_string().contains("secret-ish"));
1340 }
1341
1342 #[test]
1343 fn distinguishes_invalid_character_from_overflow() {
1344 let invalid = "11111111111111111111111111111111111111111110";
1345 assert_eq!(
1346 inspect_public_identity(invalid.to_owned()).unwrap_err(),
1347 MobileError::InvalidAddressCharacter
1348 );
1349
1350 assert_eq!(
1351 inspect_public_identity("z".repeat(44)).unwrap_err(),
1352 MobileError::AddressOverflow
1353 );
1354 }
1355
1356 #[test]
1357 fn binding_hint_input_requires_exactly_three_bytes() {
1358 let error = render_node_hint(vec![0, 1]).unwrap_err();
1359 assert_eq!(error, MobileError::InvalidNodeHintLength);
1360 assert_eq!(error.to_string(), "NODE_HINT_INVALID_LENGTH");
1361 }
1362
1363 #[test]
1364 fn raw_public_identity_uses_the_canonical_renderer() {
1365 let bytes: Vec<u8> = (0u8..32).collect();
1366 let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1367 let address = umsh_core::base58::encode(&bytes.try_into().unwrap())
1368 .into_iter()
1369 .map(char::from)
1370 .collect::<String>();
1371 assert_eq!(identity, inspect_public_identity(address).unwrap());
1372 assert_eq!(
1373 inspect_public_identity_bytes(vec![0; 31]).unwrap_err(),
1374 MobileError::InvalidPublicKeyLength
1375 );
1376 }
1377
1378 #[test]
1379 fn public_identity_bytes_round_trip_canonical_address() {
1380 let bytes: Vec<u8> = (0u8..32).collect();
1381 let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1382 assert_eq!(
1383 public_identity_bytes(identity.canonical_address).unwrap(),
1384 bytes
1385 );
1386 }
1387
1388 #[test]
1389 fn peer_identity_accepts_uri_base58_and_hex() {
1390 let key = [0xAB; 32];
1391 let canonical = public_identity_record(&PublicKey(key)).canonical_address;
1392 let hex = key
1393 .iter()
1394 .map(|byte| format!("{byte:02x}"))
1395 .collect::<String>();
1396 let colon_hex = key
1397 .iter()
1398 .map(|byte| format!("{byte:02x}"))
1399 .collect::<Vec<_>>()
1400 .join(":");
1401
1402 for input in [
1403 canonical.clone(),
1404 format!("0x{hex}"),
1405 colon_hex,
1406 format!("umsh:n:{canonical}"),
1407 ] {
1408 let preview = inspect_peer_identity(input).unwrap();
1409 assert_eq!(preview.canonical_address, canonical);
1410 }
1411 }
1412
1413 #[test]
1414 fn peer_identity_rejects_wrong_length_hex() {
1415 assert!(inspect_peer_identity("ab12".into()).is_err());
1416 }
1417
1418 #[test]
1419 fn secret_identity_derivation_returns_only_valid_public_material() {
1420 let identity = MobileIdentity::unlock(vec![7; 32])
1421 .unwrap()
1422 .public_identity();
1423 assert_eq!(identity.canonical_address.len(), 44);
1424 assert_eq!(
1425 inspect_public_identity(identity.canonical_address.clone()).unwrap(),
1426 identity
1427 );
1428
1429 assert!(matches!(
1430 MobileIdentity::unlock(vec![7; 31]),
1431 Err(MobileError::InvalidSecretKeyLength)
1432 ));
1433 }
1434}