use std::fmt;
use std::str::FromStr;
use multibase::Base;
use radicle::crypto::ssh::keystore::Keystore;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use url::Url;
use crate::Error;
const HOST_BASE: Base = Base::Base32Lower;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EndpointId(iroh_base::EndpointId);
impl EndpointId {
pub const URL_SCHEME: &'static str = "radiroh";
const LEGACY_URL_SCHEME: &'static str = "iroh";
pub fn as_inner(&self) -> &iroh_base::EndpointId {
&self.0
}
pub fn into_inner(self) -> iroh_base::EndpointId {
self.0
}
pub fn to_url(&self) -> Url {
Url::parse(&format!(
"{}://{}",
Self::URL_SCHEME,
HOST_BASE.encode(self.0.as_bytes())
))
.expect("radiroh:// URL with valid base32 host always parses")
}
pub fn from_url(url: &Url) -> Result<Option<Self>, Error> {
if url.scheme() != Self::URL_SCHEME {
return Err(Error::Key(format!(
"expected {}:// scheme, got '{}'",
Self::URL_SCHEME,
url.scheme()
)));
}
match url.host_str() {
Some(host) if !host.is_empty() => Self::from_base32(host).map(Some),
_ => Ok(None),
}
}
pub fn is_endpoint_url(url: &Url) -> bool {
url.scheme() == Self::URL_SCHEME
}
pub fn matches_url(&self, url: &Url) -> bool {
match Self::from_url(url) {
Ok(Some(id)) => id == *self,
Ok(None) => true,
Err(_) => false,
}
}
pub fn is_legacy_endpoint_url(url: &Url) -> bool {
url.scheme() == Self::LEGACY_URL_SCHEME
}
fn from_base32(host: &str) -> Result<Self, Error> {
let bytes = HOST_BASE
.decode(host)
.map_err(|e| Error::Key(format!("invalid base32 endpoint id '{host}': {e}")))?;
let arr: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
Error::Key(format!(
"endpoint id must decode to 32 bytes, got {}",
v.len()
))
})?;
let inner = iroh_base::EndpointId::from_bytes(&arr)
.map_err(|e| Error::Key(format!("invalid endpoint id bytes: {e}")))?;
Ok(Self(inner))
}
}
impl From<iroh_base::EndpointId> for EndpointId {
fn from(id: iroh_base::EndpointId) -> Self {
Self(id)
}
}
impl From<EndpointId> for iroh_base::EndpointId {
fn from(id: EndpointId) -> Self {
id.0
}
}
impl fmt::Display for EndpointId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}://{}",
Self::URL_SCHEME,
HOST_BASE.encode(self.0.as_bytes())
)
}
}
impl fmt::Debug for EndpointId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EndpointId({self})")
}
}
impl FromStr for EndpointId {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = Url::parse(s).map_err(|e| Error::Key(format!("invalid URL '{s}': {e}")))?;
Self::from_url(&url)?
.ok_or_else(|| Error::Key(format!("URL '{s}' has no endpoint id host")))
}
}
impl Serialize for EndpointId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for EndpointId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
impl TryFrom<&radicle::identity::Did> for EndpointId {
type Error = Error;
fn try_from(did: &radicle::identity::Did) -> Result<Self, Self::Error> {
let bytes = did.to_byte_array();
let inner = iroh_base::EndpointId::from_bytes(&bytes)
.map_err(|e| Error::Key(format!("invalid iroh public key from DID: {e}")))?;
Ok(EndpointId(inner))
}
}
pub fn radicle_secret_to_iroh(
keystore: &Keystore,
passphrase: Option<radicle::crypto::ssh::keystore::Passphrase>,
) -> Result<iroh_base::SecretKey, Error> {
let sk = keystore
.secret_key(passphrase)
.map_err(|e| Error::Key(format!("failed to read radicle secret key: {e}")))?
.ok_or_else(|| Error::Key("radicle secret key not found".into()))?;
let seed = sk.seed();
let seed_bytes: &[u8; 32] = &seed;
Ok(iroh_base::SecretKey::from_bytes(seed_bytes))
}
#[cfg(test)]
mod tests {
use radicle::crypto::ssh::keystore::Passphrase;
use super::*;
fn fixed_id() -> EndpointId {
iroh_base::SecretKey::from_bytes(&[7u8; 32]).public().into()
}
#[test]
fn radicle_and_iroh_keys_share_same_public_identity() {
let tmp = tempfile::tempdir().unwrap();
let keystore = Keystore::new(&tmp);
keystore
.init("test", None, radicle::crypto::Seed::generate())
.unwrap();
let iroh_sk = radicle_secret_to_iroh(&keystore, None).unwrap();
let radicle_pk = keystore.public_key().unwrap().unwrap();
let iroh_pk = iroh_sk.public();
assert_eq!(&radicle_pk.to_byte_array(), iroh_pk.as_bytes());
}
#[test]
fn display_is_endpoint_url() {
let id = fixed_id();
let s = id.to_string();
assert!(s.starts_with("radiroh://"));
assert!(!s["radiroh://".len()..].is_empty());
}
#[test]
fn display_differs_from_iroh_default() {
let inner = iroh_base::SecretKey::from_bytes(&[7u8; 32]).public();
let wrapped = EndpointId::from(inner);
assert_ne!(wrapped.to_string(), inner.to_string());
}
#[test]
fn url_round_trip() {
let id = fixed_id();
let url = id.to_url();
let parsed = EndpointId::from_url(&url).unwrap().unwrap();
assert_eq!(parsed, id);
}
#[test]
fn fromstr_round_trip() {
let id = fixed_id();
let s = id.to_string();
let parsed: EndpointId = s.parse().unwrap();
assert_eq!(parsed, id);
assert_eq!(parsed.to_string(), s);
}
#[test]
fn from_url_bare_is_none() {
let url = Url::parse("radiroh://").unwrap();
assert_eq!(EndpointId::from_url(&url).unwrap(), None);
}
#[test]
fn from_url_wrong_scheme_errors() {
let url = Url::parse("https://example.com").unwrap();
assert!(EndpointId::from_url(&url).is_err());
}
#[test]
fn from_url_garbage_host_errors() {
let url = Url::parse("radiroh://abc123").unwrap();
assert!(EndpointId::from_url(&url).is_err());
}
#[test]
fn from_url_rejects_legacy_iroh_scheme() {
let bare = Url::parse("iroh://").unwrap();
assert!(EndpointId::from_url(&bare).is_err());
let id = fixed_id();
let with_host = Url::parse(&format!(
"iroh://{}",
HOST_BASE.encode(id.as_inner().as_bytes())
))
.unwrap();
assert!(EndpointId::from_url(&with_host).is_err());
}
#[test]
fn is_endpoint_url_only_matches_endpoint_scheme() {
assert!(EndpointId::is_endpoint_url(
&Url::parse("radiroh://abc").unwrap()
));
assert!(!EndpointId::is_endpoint_url(
&Url::parse("https://example.com").unwrap()
));
assert!(!EndpointId::is_endpoint_url(
&Url::parse("iroh://abc").unwrap()
));
}
#[test]
fn is_legacy_endpoint_url_matches_only_iroh_scheme() {
assert!(EndpointId::is_legacy_endpoint_url(
&Url::parse("iroh://abc").unwrap()
));
assert!(EndpointId::is_legacy_endpoint_url(
&Url::parse("iroh://").unwrap()
));
assert!(!EndpointId::is_legacy_endpoint_url(
&Url::parse("radiroh://abc").unwrap()
));
assert!(!EndpointId::is_legacy_endpoint_url(
&Url::parse("https://example.com").unwrap()
));
}
#[test]
fn matches_url_rules() {
let id = fixed_id();
let other = iroh_base::SecretKey::from_bytes(&[9u8; 32]).public().into();
assert!(id.matches_url(&Url::parse("radiroh://").unwrap()));
assert!(id.matches_url(&id.to_url()));
assert!(!id.matches_url(&EndpointId::to_url(&other)));
assert!(!id.matches_url(&Url::parse("https://example.com").unwrap()));
assert!(!id.matches_url(&Url::parse("radiroh://abc123").unwrap()));
}
#[test]
fn encrypted_keystore_requires_passphrase() {
let tmp = tempfile::tempdir().unwrap();
let keystore = Keystore::new(&tmp);
keystore
.init(
"test",
Some(Passphrase::new("hunter2".into())),
radicle::crypto::Seed::generate(),
)
.unwrap();
assert!(radicle_secret_to_iroh(&keystore, None).is_err());
let iroh_sk =
radicle_secret_to_iroh(&keystore, Some(Passphrase::new("hunter2".into()))).unwrap();
let radicle_pk = keystore.public_key().unwrap().unwrap();
assert_eq!(&radicle_pk.to_byte_array(), iroh_sk.public().as_bytes());
}
}