use crate::crypto::{AeadKeyBytes, derive_key32, hkdf_sha256};
pub const CHANNEL_ID_LEN: usize = 16;
pub const IROH_TOPIC_ID_LEN: usize = 32;
pub const MAX_NAME_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChannelKind {
Mailbox,
Slot,
}
impl ChannelKind {
#[inline]
pub const fn as_bytes(self) -> &'static [u8] {
match self {
ChannelKind::Mailbox => b"mailbox",
ChannelKind::Slot => b"slot",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransportKind {
Http,
Pkarr,
Dht,
Iroh,
}
impl TransportKind {
#[inline]
pub const fn as_bytes(self) -> &'static [u8] {
match self {
TransportKind::Http => b"http",
TransportKind::Pkarr => b"pkarr",
TransportKind::Dht => b"dht",
TransportKind::Iroh => b"iroh",
}
}
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
TransportKind::Http => "http",
TransportKind::Pkarr => "pkarr",
TransportKind::Dht => "dht",
TransportKind::Iroh => "iroh",
}
}
}
impl core::fmt::Display for TransportKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameError {
Empty,
TooLong,
InvalidChar,
}
impl core::fmt::Display for NameError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
NameError::Empty => f.write_str("channel name is empty"),
NameError::TooLong => f.write_str("channel name exceeds 64 bytes"),
NameError::InvalidChar => {
f.write_str("channel name contains a byte outside [0-9 a-z - _ / .]")
}
}
}
}
impl std::error::Error for NameError {}
pub fn validate_name(name: &str) -> Result<(), NameError> {
if name.is_empty() {
return Err(NameError::Empty);
}
if name.len() > MAX_NAME_LEN {
return Err(NameError::TooLong);
}
for &b in name.as_bytes() {
let ok = matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'/' | b'.');
if !ok {
return Err(NameError::InvalidChar);
}
}
Ok(())
}
pub fn channel_aead_key(
seed: &[u8; 32],
kind: ChannelKind,
name: &str,
) -> Result<AeadKeyBytes, NameError> {
validate_name(name)?;
let kind_bytes = kind.as_bytes();
let name_bytes = name.as_bytes();
let mut info =
Vec::with_capacity(b"enlace/v1/key/aead/".len() + kind_bytes.len() + 1 + name_bytes.len());
info.extend_from_slice(b"enlace/v1/key/aead/");
info.extend_from_slice(kind_bytes);
info.push(b'/');
info.extend_from_slice(name_bytes);
Ok(derive_key32(seed, &info))
}
pub fn channel_id(
seed: &[u8; 32],
transport: TransportKind,
kind: ChannelKind,
name: &str,
) -> Result<[u8; CHANNEL_ID_LEN], NameError> {
validate_name(name)?;
let transport_bytes = transport.as_bytes();
let kind_bytes = kind.as_bytes();
let name_bytes = name.as_bytes();
let mut info = Vec::with_capacity(
b"enlace/v1/id/".len()
+ transport_bytes.len()
+ 1
+ kind_bytes.len()
+ 1
+ name_bytes.len(),
);
info.extend_from_slice(b"enlace/v1/id/");
info.extend_from_slice(transport_bytes);
info.push(b'/');
info.extend_from_slice(kind_bytes);
info.push(b'/');
info.extend_from_slice(name_bytes);
let mut out = [0u8; CHANNEL_ID_LEN];
hkdf_sha256(seed, b"", &info, &mut out);
Ok(out)
}
pub fn iroh_topic_id(
seed: &[u8; 32],
kind: ChannelKind,
name: &str,
) -> Result<[u8; IROH_TOPIC_ID_LEN], NameError> {
validate_name(name)?;
let kind_bytes = kind.as_bytes();
let name_bytes = name.as_bytes();
let mut info = Vec::with_capacity(
b"enlace/v1/id/iroh-topic/".len() + kind_bytes.len() + 1 + name_bytes.len(),
);
info.extend_from_slice(b"enlace/v1/id/iroh-topic/");
info.extend_from_slice(kind_bytes);
info.push(b'/');
info.extend_from_slice(name_bytes);
let mut out = [0u8; IROH_TOPIC_ID_LEN];
hkdf_sha256(seed, b"", &info, &mut out);
Ok(out)
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
const SEED_A: [u8; 32] = [0xA1u8; 32];
const SEED_B: [u8; 32] = [0xB2u8; 32];
#[test]
fn name_empty_rejected() {
assert_eq!(validate_name(""), Err(NameError::Empty));
}
#[test]
fn name_max_length_accepted() {
let n = "a".repeat(MAX_NAME_LEN);
assert!(validate_name(&n).is_ok());
}
#[test]
fn name_over_max_rejected() {
let n = "a".repeat(MAX_NAME_LEN + 1);
assert_eq!(validate_name(&n), Err(NameError::TooLong));
}
#[test]
fn name_full_charset_accepted() {
let all_ok = "abcdefghijklmnopqrstuvwxyz0123456789-_./";
assert!(validate_name(all_ok).is_ok());
}
#[test]
fn name_uppercase_rejected() {
assert_eq!(validate_name("Foo"), Err(NameError::InvalidChar));
assert_eq!(validate_name("FOO"), Err(NameError::InvalidChar));
}
#[test]
fn name_space_rejected() {
assert_eq!(validate_name("a b"), Err(NameError::InvalidChar));
}
#[test]
fn name_special_ascii_bytes_rejected() {
for bad in [b':', b'+', b'=', b'@', b'#', b'%', b'*', b'~', b' ', b'\t'] {
let raw = [b'a', bad, b'b'];
let s = std::str::from_utf8(&raw).expect("ASCII test bytes are valid UTF-8");
assert_eq!(
validate_name(s),
Err(NameError::InvalidChar),
"byte 0x{bad:02X} should be rejected"
);
}
}
#[test]
fn name_uppercase_full_alphabet_rejected() {
for c in b'A'..=b'Z' {
let raw = [c];
let s = std::str::from_utf8(&raw).unwrap();
assert_eq!(
validate_name(s),
Err(NameError::InvalidChar),
"uppercase {s} should be rejected"
);
}
}
#[test]
fn name_single_byte_accepted() {
assert!(validate_name("a").is_ok());
assert!(validate_name("0").is_ok());
assert!(validate_name("-").is_ok());
assert!(validate_name("/").is_ok());
assert!(validate_name(".").is_ok());
assert!(validate_name("_").is_ok());
}
#[test]
fn aead_key_deterministic() {
let k1 = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let k2 = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
assert_eq!(k1.as_ref(), k2.as_ref());
assert_eq!(k1.as_ref().len(), crate::crypto::AEAD_KEY_LEN);
}
#[test]
fn aead_key_diverges_by_kind() {
let mailbox = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let slot = channel_aead_key(&SEED_A, ChannelKind::Slot, "alpha").unwrap();
assert_ne!(mailbox.as_ref(), slot.as_ref());
}
#[test]
fn aead_key_diverges_by_name() {
let a = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let b = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "beta").unwrap();
assert_ne!(a.as_ref(), b.as_ref());
}
#[test]
fn aead_key_diverges_by_seed() {
let a = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let b = channel_aead_key(&SEED_B, ChannelKind::Mailbox, "alpha").unwrap();
assert_ne!(a.as_ref(), b.as_ref());
}
#[test]
fn aead_key_rejects_invalid_name() {
assert_eq!(
channel_aead_key(&SEED_A, ChannelKind::Mailbox, "").err(),
Some(NameError::Empty)
);
assert_eq!(
channel_aead_key(&SEED_A, ChannelKind::Slot, "Bad").err(),
Some(NameError::InvalidChar)
);
}
#[test]
fn channel_id_deterministic_and_sized() {
let id1 = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
let id2 = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
assert_eq!(id1, id2);
assert_eq!(id1.len(), CHANNEL_ID_LEN);
}
#[test]
fn channel_id_diverges_by_transport() {
let http = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
let pkarr =
channel_id(&SEED_A, TransportKind::Pkarr, ChannelKind::Mailbox, "alpha").unwrap();
let dht = channel_id(&SEED_A, TransportKind::Dht, ChannelKind::Mailbox, "alpha").unwrap();
let iroh = channel_id(&SEED_A, TransportKind::Iroh, ChannelKind::Mailbox, "alpha").unwrap();
let all = [http, pkarr, dht, iroh];
for i in 0..all.len() {
for j in (i + 1)..all.len() {
assert_ne!(all[i], all[j], "transports {i} and {j} produced equal ids");
}
}
}
#[test]
fn channel_id_diverges_by_kind() {
let mailbox =
channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
let slot = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Slot, "alpha").unwrap();
assert_ne!(mailbox, slot);
}
#[test]
fn channel_id_diverges_by_name() {
let a = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
let b = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "beta").unwrap();
assert_ne!(a, b);
}
#[test]
fn channel_id_diverges_by_seed() {
let a = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
let b = channel_id(&SEED_B, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
assert_ne!(a, b);
}
#[test]
fn channel_id_rejects_invalid_name() {
assert_eq!(
channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "").err(),
Some(NameError::Empty)
);
let too_long = "a".repeat(MAX_NAME_LEN + 1);
assert_eq!(
channel_id(
&SEED_A,
TransportKind::Http,
ChannelKind::Mailbox,
&too_long
)
.err(),
Some(NameError::TooLong)
);
}
#[test]
fn iroh_topic_id_deterministic_and_sized() {
let id1 = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let id2 = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
assert_eq!(id1, id2);
assert_eq!(id1.len(), IROH_TOPIC_ID_LEN);
}
#[test]
fn iroh_topic_id_diverges_by_kind() {
let mailbox = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let slot = iroh_topic_id(&SEED_A, ChannelKind::Slot, "alpha").unwrap();
assert_ne!(mailbox, slot);
}
#[test]
fn iroh_topic_id_diverges_by_name() {
let a = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let b = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "beta").unwrap();
assert_ne!(a, b);
}
#[test]
fn iroh_topic_id_diverges_by_seed() {
let a = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let b = iroh_topic_id(&SEED_B, ChannelKind::Mailbox, "alpha").unwrap();
assert_ne!(a, b);
}
#[test]
fn iroh_topic_id_rejects_invalid_name() {
assert_eq!(
iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "").err(),
Some(NameError::Empty)
);
assert_eq!(
iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "Bad").err(),
Some(NameError::InvalidChar)
);
}
#[test]
fn aead_key_and_channel_id_share_no_prefix() {
let key = channel_aead_key(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let id = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, "alpha").unwrap();
assert_ne!(&key.as_ref()[..CHANNEL_ID_LEN], &id[..]);
}
#[test]
fn iroh_topic_id_and_channel_id_share_no_prefix() {
let topic = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, "alpha").unwrap();
let id = channel_id(&SEED_A, TransportKind::Iroh, ChannelKind::Mailbox, "alpha").unwrap();
assert_ne!(&topic[..CHANNEL_ID_LEN], &id[..]);
}
proptest! {
#[test]
fn valid_names_derive_deterministic_keys_and_ids(name in "[a-z0-9_./-]{1,64}") {
let key_a = channel_aead_key(&SEED_A, ChannelKind::Mailbox, &name).unwrap();
let key_b = channel_aead_key(&SEED_A, ChannelKind::Mailbox, &name).unwrap();
prop_assert_eq!(key_a.as_ref(), key_b.as_ref());
let id_a = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, &name).unwrap();
let id_b = channel_id(&SEED_A, TransportKind::Http, ChannelKind::Mailbox, &name).unwrap();
prop_assert_eq!(id_a, id_b);
let topic_a = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, &name).unwrap();
let topic_b = iroh_topic_id(&SEED_A, ChannelKind::Mailbox, &name).unwrap();
prop_assert_eq!(topic_a, topic_b);
}
#[test]
fn names_outside_allowed_charset_are_rejected(name in ".{1,64}") {
prop_assume!(name.as_bytes().iter().any(|b| !matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'-' | b'_' | b'/' | b'.')));
prop_assert!(validate_name(&name).is_err());
}
}
}