diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8108108ea4..3517ee259e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,15 +91,18 @@ jobs: RUST_LOG: ${{ runner.debug && 'DEBUG' || 'INFO'}} - name: tests - run: cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests + run: | + cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests env: - RUST_LOG: ${{ runner.debug && 'DEBUG' || 'INFO'}} + RUST_LOG: "TRACE" - name: doctests if: ${{ matrix.features == 'all' }} - run: cargo test --workspace --all-features --doc - env: - RUST_LOG: ${{ runner.debug && 'DEBUG' || 'INFO'}} + run: | + if [ -n "${{ runner.debug }}" ]; then + export RUST_LOG=DEBUG + fi + cargo test --workspace --all-features --doc build_and_test_windows: timeout-minutes: 30 @@ -168,9 +171,10 @@ jobs: - uses: msys2/setup-msys2@v2 - name: tests - run: cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --target ${{ matrix.target }} + run: | + cargo nextest run --workspace ${{ env.FEATURES }} --lib --bins --tests --target ${{ matrix.target }} env: - RUST_LOG: ${{ runner.debug && 'DEBUG' || 'INFO'}} + RUST_LOG: "TRACE" cross: timeout-minutes: 30 diff --git a/Cargo.lock b/Cargo.lock index b0ef1bf0ab..42d87d09a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2403,6 +2403,7 @@ dependencies = [ "smallvec", "socket2 0.5.5", "ssh-key", + "strum", "stun-rs", "surge-ping", "testdir", diff --git a/iroh-base/src/hash.rs b/iroh-base/src/hash.rs index 5c1b6360f4..f4bf231e52 100644 --- a/iroh-base/src/hash.rs +++ b/iroh-base/src/hash.rs @@ -5,11 +5,7 @@ use std::str::FromStr; use bao_tree::blake3; use postcard::experimental::max_size::MaxSize; -use serde::{ - de::{self, SeqAccess}, - ser::SerializeTuple, - Deserialize, Deserializer, Serialize, Serializer, -}; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; /// Hash type used throughout. #[derive(PartialEq, Eq, Copy, Clone, Hash)] @@ -36,7 +32,7 @@ impl Hash { 201, 173, 193, 18, 183, 204, 154, 147, 202, 228, 31, 50, 98, ]); - /// Calculate the hash of the provide bytes. + /// Calculate the hash of the provided bytes. pub fn new(buf: impl AsRef<[u8]>) -> Self { let val = blake3::hash(buf.as_ref()); Hash(val) @@ -159,13 +155,7 @@ impl Serialize for Hash { if serializer.is_human_readable() { serializer.serialize_str(self.to_string().as_str()) } else { - // Fixed-length structures, including arrays, are supported in Serde as tuples - // See: https://serde.rs/impl-serialize.html#serializing-a-tuple - let mut s = serializer.serialize_tuple(32)?; - for item in self.0.as_bytes() { - s.serialize_element(item)?; - } - s.end() + self.0.as_bytes().serialize(serializer) } } } @@ -179,39 +169,12 @@ impl<'de> Deserialize<'de> for Hash { let s = String::deserialize(deserializer)?; s.parse().map_err(de::Error::custom) } else { - deserializer.deserialize_tuple(32, HashVisitor) + let data: [u8; 32] = Deserialize::deserialize(deserializer)?; + Ok(Self(blake3::Hash::from(data))) } } } -struct HashVisitor; - -impl<'de> de::Visitor<'de> for HashVisitor { - type Value = Hash; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "an array of 32 bytes containing hash data") - } - - /// Process a sequence into an array - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - let mut arr = [0u8; 32]; - let mut i = 0; - while let Some(val) = seq.next_element()? { - arr[i] = val; - i += 1; - if i > 32 { - return Err(de::Error::invalid_length(i, &self)); - } - } - - Ok(Hash::from(arr)) - } -} - impl MaxSize for Hash { const POSTCARD_MAX_SIZE: usize = 32; } diff --git a/iroh-base/src/lib.rs b/iroh-base/src/lib.rs index 08219a07ab..096f751822 100644 --- a/iroh-base/src/lib.rs +++ b/iroh-base/src/lib.rs @@ -5,3 +5,5 @@ pub mod base32; #[cfg(feature = "hash")] pub mod hash; pub mod rpc; +#[cfg(feature = "base32")] +pub mod ticket; diff --git a/iroh-base/src/ticket.rs b/iroh-base/src/ticket.rs new file mode 100644 index 0000000000..34da624da2 --- /dev/null +++ b/iroh-base/src/ticket.rs @@ -0,0 +1,63 @@ +use crate::base32; + +/// A ticket is a serializable object that combines all information required +/// for an operation. E.g. an iroh blob ticket would contain the hash of the +/// data as well as information about how to reach the provider. +/// +/// Tickets support serialization to a string using base32 encoding. The kind of +/// ticket will be prepended to the string to make it somewhat self describing. +/// +/// Versioning is left to the implementer. Some kinds of tickets might need +/// versioning, others might not. +/// +/// The serialization format for converting the ticket from and to bytes is left +/// to the implementer. We recommend using [postcard] for serialization. +/// +/// [postcard]: https://docs.rs/postcard/latest/postcard/ +pub trait Ticket: Sized { + /// String prefix describing the kind of iroh ticket. + /// + /// This should be lower case ascii characters. + const KIND: &'static str; + + /// Serialize to bytes used in the base32 string representation. + fn to_bytes(&self) -> Vec; + + /// Deserialize from the base32 string representation bytes. + fn from_bytes(bytes: &[u8]) -> Result; + + /// Serialize to string. + fn serialize(&self) -> String { + let mut out = Self::KIND.to_string(); + base32::fmt_append(&self.to_bytes(), &mut out); + out + } + + /// Deserialize from a string. + fn deserialize(str: &str) -> Result { + let expected = Self::KIND; + let Some(rest) = str.strip_prefix(expected) else { + return Err(Error::Kind { expected }); + }; + let bytes = base32::parse_vec(rest)?; + let ticket = Self::from_bytes(&bytes)?; + Ok(ticket) + } +} + +/// An error deserializing an iroh ticket. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Found a ticket of with the wrong prefix, indicating the wrong kind. + #[error("wrong prefix, expected {expected}")] + Kind { expected: &'static str }, + /// This looks like a ticket, but postcard deserialization failed. + #[error("deserialization failed: {_0}")] + Postcard(#[from] postcard::Error), + /// This looks like a ticket, but base32 decoding failed. + #[error("decoding failed: {_0}")] + Encoding(#[from] base32::DecodeError), + /// Verification of the deserialized bytes failed. + #[error("verification failed: {_0}")] + Verify(&'static str), +} diff --git a/iroh-net/Cargo.toml b/iroh-net/Cargo.toml index efc9531bd4..91aff47c19 100644 --- a/iroh-net/Cargo.toml +++ b/iroh-net/Cargo.toml @@ -58,6 +58,7 @@ serdect = "0.2.0" smallvec = "1.11.1" socket2 = "0.5.3" ssh-key = { version = "0.6.0", features = ["ed25519", "std", "rand_core"] } +strum = { version = "0.25.0", features = ["derive"] } stun-rs = "0.1.5" surge-ping = "0.8.0" thiserror = "1" diff --git a/iroh-net/src/key.rs b/iroh-net/src/key.rs index 1fc2a8a338..854b0d3be1 100644 --- a/iroh-net/src/key.rs +++ b/iroh-net/src/key.rs @@ -106,7 +106,7 @@ impl Serialize for PublicKey { if serializer.is_human_readable() { serializer.serialize_str(&self.to_string()) } else { - serializer.serialize_bytes(&self.0) + self.0.serialize(serializer) } } } @@ -120,8 +120,8 @@ impl<'de> Deserialize<'de> for PublicKey { let s = String::deserialize(deserializer)?; Self::from_str(&s).map_err(serde::de::Error::custom) } else { - let bytes: &serde_bytes::Bytes = serde::Deserialize::deserialize(deserializer)?; - Self::try_from(bytes.as_ref()).map_err(serde::de::Error::custom) + let data: [u8; 32] = serde::Deserialize::deserialize(deserializer)?; + Self::try_from(data.as_ref()).map_err(serde::de::Error::custom) } } } @@ -396,8 +396,25 @@ impl TryFrom<&[u8]> for SecretKey { #[cfg(test)] mod tests { + use iroh_test::{assert_eq_hex, hexdump::parse_hexdump}; + use super::*; + #[test] + fn test_public_key_postcard() { + let public_key = PublicKey::try_from( + hex::decode("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap() + .as_slice(), + ) + .unwrap(); + let bytes = postcard::to_stdvec(&public_key).unwrap(); + let expected = + parse_hexdump("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap(); + assert_eq_hex!(bytes, expected); + } + #[test] fn test_secret_key_openssh_roundtrip() { let kp = SecretKey::generate(); @@ -463,7 +480,8 @@ mod tests { let k5 = random_verifying_key(); let bytes = postcard::to_stdvec(&k5).unwrap(); - let _key: PublicKey = postcard::from_bytes(&bytes).unwrap(); + // VerifyingKey serialises with a length prefix, PublicKey does not. + let _key: PublicKey = postcard::from_bytes(&bytes[1..]).unwrap(); assert!(lock_key_cache().contains_key(k5.as_bytes())); } } diff --git a/iroh-net/src/lib.rs b/iroh-net/src/lib.rs index e7183d78f8..c7b27b2764 100644 --- a/iroh-net/src/lib.rs +++ b/iroh-net/src/lib.rs @@ -24,6 +24,7 @@ pub mod netcheck; pub mod ping; pub mod portmapper; pub mod stun; +pub mod ticket; pub mod tls; pub mod util; diff --git a/iroh-net/src/ticket.rs b/iroh-net/src/ticket.rs new file mode 100644 index 0000000000..7fd270e5a0 --- /dev/null +++ b/iroh-net/src/ticket.rs @@ -0,0 +1,6 @@ +//! Tickets supported by iroh-net +mod blob; +mod node; + +pub use blob::BlobTicket; +pub use node::NodeTicket; diff --git a/iroh/src/ticket/blob.rs b/iroh-net/src/ticket/blob.rs similarity index 50% rename from iroh/src/ticket/blob.rs rename to iroh-net/src/ticket/blob.rs index 6f3de77368..33ef1de73f 100644 --- a/iroh/src/ticket/blob.rs +++ b/iroh-net/src/ticket/blob.rs @@ -1,22 +1,21 @@ //! Tickets for blobs. - use std::str::FromStr; use anyhow::{ensure, Result}; -use iroh_bytes::{BlobFormat, Hash}; -use iroh_net::{derp::DerpMap, key::SecretKey, NodeAddr}; +use iroh_base::{ + hash::{BlobFormat, Hash}, + ticket::{self, Ticket}, +}; use serde::{Deserialize, Serialize}; -use crate::dial::Options; - -use super::*; +use crate::NodeAddr; /// A token containing everything to get a file from the provider. /// /// It is a single item which can be easily serialized and deserialized. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] -#[display("{}", IrohTicket::serialize(self))] -pub struct Ticket { +#[display("{}", Ticket::serialize(self))] +pub struct BlobTicket { /// The provider to get a file from. node: NodeAddr, /// The format of the blob. @@ -25,26 +24,43 @@ pub struct Ticket { hash: Hash, } -impl IrohTicket for Ticket { - const KIND: Kind = Kind::Blob; +/// Wire format for [`BlobTicket`]. +/// +/// In the future we might have multiple variants (not versions, since they +/// might be both equally valid), so this is a single variant enum to force +/// postcard to add a discriminator. +#[derive(Serialize, Deserialize)] +enum TicketWireFormat { + Variant0(BlobTicket), +} + +impl Ticket for BlobTicket { + const KIND: &'static str = "blob"; + + fn to_bytes(&self) -> Vec { + let data = TicketWireFormat::Variant0(self.clone()); + postcard::to_stdvec(&data).expect("postcard serialization failed") + } - fn verify(&self) -> std::result::Result<(), &'static str> { - if self.node.info.is_empty() { - return Err("addressing info cannot be empty"); + fn from_bytes(bytes: &[u8]) -> std::result::Result { + let res: TicketWireFormat = postcard::from_bytes(bytes).map_err(ticket::Error::Postcard)?; + let TicketWireFormat::Variant0(res) = res; + if res.node.info.is_empty() { + return Err(ticket::Error::Verify("addressing info cannot be empty")); } - Ok(()) + Ok(res) } } -impl FromStr for Ticket { - type Err = Error; +impl FromStr for BlobTicket { + type Err = ticket::Error; fn from_str(s: &str) -> Result { - IrohTicket::deserialize(s) + Ticket::deserialize(s) } } -impl Ticket { +impl BlobTicket { /// Creates a new ticket. pub fn new(node: NodeAddr, hash: Hash, format: BlobFormat) -> Result { ensure!(!node.info.is_empty(), "addressing info cannot be empty"); @@ -73,33 +89,23 @@ impl Ticket { /// Get the contents of the ticket, consuming it. pub fn into_parts(self) -> (NodeAddr, Hash, BlobFormat) { - let Ticket { node, hash, format } = self; + let BlobTicket { node, hash, format } = self; (node, hash, format) } - - /// Convert this ticket into a [`Options`], adding the given secret key. - pub fn as_get_options(&self, secret_key: SecretKey, derp_map: Option) -> Options { - Options { - peer: self.node.clone(), - secret_key, - keylog: true, - derp_map, - } - } } -impl Serialize for Ticket { +impl Serialize for BlobTicket { fn serialize(&self, serializer: S) -> Result { if serializer.is_human_readable() { serializer.serialize_str(&self.to_string()) } else { - let Ticket { node, format, hash } = self; + let BlobTicket { node, format, hash } = self; (node, format, hash).serialize(serializer) } } } -impl<'de> Deserialize<'de> for Ticket { +impl<'de> Deserialize<'de> for BlobTicket { fn deserialize>(deserializer: D) -> Result { if deserializer.is_human_readable() { let s = String::deserialize(deserializer)?; @@ -113,19 +119,20 @@ impl<'de> Deserialize<'de> for Ticket { #[cfg(test)] mod tests { - use std::net::SocketAddr; + use iroh_base::base32; + use iroh_test::{assert_eq_hex, hexdump::parse_hexdump}; - use bao_tree::blake3; + use crate::key::{PublicKey, SecretKey}; + use std::net::SocketAddr; use super::*; - fn make_ticket() -> Ticket { - let hash = blake3::hash(b"hi there"); - let hash = Hash::from(hash); + fn make_ticket() -> BlobTicket { + let hash = Hash::new(b"hi there"); let peer = SecretKey::generate().public(); let addr = SocketAddr::from_str("127.0.0.1:1234").unwrap(); let derp_url = None; - Ticket { + BlobTicket { hash, node: NodeAddr::from_parts(peer, derp_url, vec![addr]), format: BlobFormat::HashSeq, @@ -136,7 +143,7 @@ mod tests { fn test_ticket_postcard() { let ticket = make_ticket(); let bytes = postcard::to_stdvec(&ticket).unwrap(); - let ticket2: Ticket = postcard::from_bytes(&bytes).unwrap(); + let ticket2: BlobTicket = postcard::from_bytes(&bytes).unwrap(); assert_eq!(ticket2, ticket); } @@ -144,18 +151,38 @@ mod tests { fn test_ticket_json() { let ticket = make_ticket(); let json = serde_json::to_string(&ticket).unwrap(); - let ticket2: Ticket = serde_json::from_str(&json).unwrap(); + let ticket2: BlobTicket = serde_json::from_str(&json).unwrap(); assert_eq!(ticket2, ticket); } #[test] - fn test_ticket_base32_roundtrip() { - let ticket = make_ticket(); - let base32 = ticket.to_string(); - println!("Ticket: {base32}"); - println!("{} bytes", base32.len()); - - let ticket2: Ticket = base32.parse().unwrap(); - assert_eq!(ticket2, ticket); + fn test_ticket_base32() { + let hash = + Hash::from_str("0b84d358e4c8be6c38626b2182ff575818ba6bd3f4b90464994be14cb354a072") + .unwrap(); + let node_id = PublicKey::from_bytes( + &<[u8; 32]>::try_from( + hex::decode("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + + let ticket = BlobTicket { + node: NodeAddr::from_parts(node_id, None, vec![]), + format: BlobFormat::Raw, + hash, + }; + let base32 = base32::parse_vec(ticket.to_string().strip_prefix("blob").unwrap()).unwrap(); + let expected = parse_hexdump(" + 00 # discriminator for variant 0 + ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6 # node id, 32 bytes, see above + 00 # derp url + 00 # number of addresses (0) + 00 # format (raw) + 0b84d358e4c8be6c38626b2182ff575818ba6bd3f4b90464994be14cb354a072 # hash, 32 bytes, see above + ").unwrap(); + assert_eq_hex!(base32, expected); } } diff --git a/iroh-net/src/ticket/node.rs b/iroh-net/src/ticket/node.rs new file mode 100644 index 0000000000..1a4c4c2195 --- /dev/null +++ b/iroh-net/src/ticket/node.rs @@ -0,0 +1,153 @@ +//! Tickets for nodes. + +use std::str::FromStr; + +use anyhow::{ensure, Result}; +use iroh_base::ticket::{self, Ticket}; +use serde::{Deserialize, Serialize}; + +use crate::NodeAddr; + +/// A token containing everything to get a file from the provider. +/// +/// It is a single item which can be easily serialized and deserialized. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[display("{}", Ticket::serialize(self))] +pub struct NodeTicket { + node: NodeAddr, +} + +/// Wire format for [`NodeTicket`]. +#[derive(Serialize, Deserialize)] +enum TicketWireFormat { + Variant0(NodeTicket), +} + +impl Ticket for NodeTicket { + const KIND: &'static str = "node"; + + fn to_bytes(&self) -> Vec { + let data = TicketWireFormat::Variant0(self.clone()); + postcard::to_stdvec(&data).expect("postcard serialization failed") + } + + fn from_bytes(bytes: &[u8]) -> std::result::Result { + let res: TicketWireFormat = postcard::from_bytes(bytes).map_err(ticket::Error::Postcard)?; + let TicketWireFormat::Variant0(res) = res; + if res.node.info.is_empty() { + return Err(ticket::Error::Verify("addressing info cannot be empty")); + } + Ok(res) + } +} + +impl FromStr for NodeTicket { + type Err = ticket::Error; + + fn from_str(s: &str) -> Result { + ticket::Ticket::deserialize(s) + } +} + +impl NodeTicket { + /// Creates a new ticket. + pub fn new(node: NodeAddr) -> Result { + ensure!(!node.info.is_empty(), "addressing info cannot be empty"); + Ok(Self { node }) + } + + /// The [`NodeAddr`] of the provider for this ticket. + pub fn node_addr(&self) -> &NodeAddr { + &self.node + } +} + +impl Serialize for NodeTicket { + fn serialize(&self, serializer: S) -> Result { + if serializer.is_human_readable() { + serializer.serialize_str(&self.to_string()) + } else { + let NodeTicket { node } = self; + (node).serialize(serializer) + } + } +} + +impl<'de> Deserialize<'de> for NodeTicket { + fn deserialize>(deserializer: D) -> Result { + if deserializer.is_human_readable() { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } else { + let peer = Deserialize::deserialize(deserializer)?; + Self::new(peer).map_err(serde::de::Error::custom) + } + } +} + +#[cfg(test)] +mod tests { + use iroh_base::base32; + use iroh_test::{assert_eq_hex, hexdump::parse_hexdump}; + + use crate::key::{PublicKey, SecretKey}; + use std::net::SocketAddr; + + use super::*; + + fn make_ticket() -> NodeTicket { + let peer = SecretKey::generate().public(); + let addr = SocketAddr::from_str("127.0.0.1:1234").unwrap(); + let derp_url = None; + NodeTicket { + node: NodeAddr::from_parts(peer, derp_url, vec![addr]), + } + } + + #[test] + fn test_ticket_postcard() { + let ticket = make_ticket(); + let bytes = postcard::to_stdvec(&ticket).unwrap(); + let ticket2: NodeTicket = postcard::from_bytes(&bytes).unwrap(); + assert_eq!(ticket2, ticket); + } + + #[test] + fn test_ticket_json() { + let ticket = make_ticket(); + let json = serde_json::to_string(&ticket).unwrap(); + let ticket2: NodeTicket = serde_json::from_str(&json).unwrap(); + assert_eq!(ticket2, ticket); + } + + #[test] + fn test_ticket_base32() { + let node_id = PublicKey::from_bytes( + &<[u8; 32]>::try_from( + hex::decode("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + + let ticket = NodeTicket { + node: NodeAddr::from_parts( + node_id, + Some("http://derp.me".parse().unwrap()), + vec!["127.0.0.1:1024".parse().unwrap()], + ), + }; + let base32 = base32::parse_vec(ticket.to_string().strip_prefix("node").unwrap()).unwrap(); + let expected = parse_hexdump(" + 00 # variant + ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6 # node id, 32 bytes, see above + 01 # derp url present + 0f 687474703a2f2f646572702e6d652f # derp url, 15 bytes, see above + 01 # one dircet address + 00 # ipv4 + 7f000001 8008 # address, see above + ").unwrap(); + assert_eq_hex!(base32, expected); + } +} diff --git a/iroh/examples/dump-blob-fsm.rs b/iroh/examples/dump-blob-fsm.rs index d36cb79945..f5054bd1a5 100644 --- a/iroh/examples/dump-blob-fsm.rs +++ b/iroh/examples/dump-blob-fsm.rs @@ -8,7 +8,8 @@ //! $ cargo run --example dump-blob-fsm use std::env::args; -use iroh::ticket::blob::Ticket; +use iroh::dial::Options; +use iroh::ticket::BlobTicket; use iroh_bytes::get::fsm::{ConnectedNext, EndBlobNext}; use iroh_bytes::protocol::GetRequest; use iroh_io::ConcatenateSliceWriter; @@ -28,13 +29,18 @@ pub fn setup_logging() { async fn main() -> anyhow::Result<()> { setup_logging(); - let ticket: Ticket = args().nth(1).expect("missing ticket").parse()?; + let ticket: BlobTicket = args().nth(1).expect("missing ticket").parse()?; // generate a transient secretkey for this connection // // in real applications, it would be very much preferable to use a persistent secret key let secret_key = SecretKey::generate(); - let dial_options = ticket.as_get_options(secret_key, None); + let dial_options = Options { + secret_key, + peer: ticket.node_addr().clone(), + keylog: false, + derp_map: None, + }; // connect to the peer // diff --git a/iroh/examples/dump-blob-stream.rs b/iroh/examples/dump-blob-stream.rs index 51f5a8467a..1045b055ae 100644 --- a/iroh/examples/dump-blob-stream.rs +++ b/iroh/examples/dump-blob-stream.rs @@ -14,7 +14,8 @@ use bytes::Bytes; use futures::{Stream, StreamExt}; use genawaiter::sync::Co; use genawaiter::sync::Gen; -use iroh::ticket::blob::Ticket; +use iroh::dial::Options; +use iroh::ticket::BlobTicket; use iroh_bytes::get::fsm::{AtInitial, BlobContentNext, ConnectedNext, EndBlobNext}; use iroh_bytes::protocol::GetRequest; use iroh_net::key::SecretKey; @@ -165,13 +166,18 @@ fn stream_children(initial: AtInitial) -> impl Stream> async fn main() -> anyhow::Result<()> { setup_logging(); - let ticket: Ticket = args().nth(1).expect("missing ticket").parse()?; + let ticket: BlobTicket = args().nth(1).expect("missing ticket").parse()?; // generate a transient secret key for this connection // // in real applications, it would be very much preferable to use a persistent secret key let secret_key = SecretKey::generate(); - let dial_options = ticket.as_get_options(secret_key, None); + let dial_options = Options { + secret_key, + peer: ticket.node_addr().clone(), + keylog: false, + derp_map: None, + }; // connect to the peer // diff --git a/iroh/src/commands/blob.rs b/iroh/src/commands/blob.rs index 542bab0368..9c605a2bba 100644 --- a/iroh/src/commands/blob.rs +++ b/iroh/src/commands/blob.rs @@ -19,7 +19,7 @@ use iroh::{ BlobDownloadRequest, DownloadLocation, NodeStatusResponse, ProviderService, SetTagOption, WrapOption, }, - ticket::blob::Ticket, + ticket::BlobTicket, }; use iroh_bytes::{ provider::{AddProgress, DownloadProgress}, @@ -115,7 +115,7 @@ pub enum BlobCommands { #[derive(Debug, Clone, derive_more::Display)] pub enum TicketOrHash { - Ticket(Ticket), + Ticket(BlobTicket), Hash(Hash), } @@ -123,7 +123,7 @@ impl std::str::FromStr for TicketOrHash { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { - if let Ok(ticket) = Ticket::from_str(s) { + if let Ok(ticket) = BlobTicket::from_str(s) { return Ok(Self::Ticket(ticket)); } if let Ok(hash) = Hash::from_str(s) { @@ -304,7 +304,7 @@ impl BlobCommands { BlobFormat::Raw }; - let ticket = Ticket::new(node_addr, hash, format).expect("correct ticket"); + let ticket = BlobTicket::new(node_addr, hash, format).expect("correct ticket"); println!( "Ticket for {blob_status} {hash} ({})\n{ticket}", HumanBytes(blob_reader.size()) @@ -690,7 +690,7 @@ pub async fn add>( print_add_response(hash, format, entries); if let TicketOption::Print = ticket { let status = client.node.status().await?; - let ticket = Ticket::new(status.addr, hash, format)?; + let ticket = BlobTicket::new(status.addr, hash, format)?; println!("All-in-one ticket: {ticket}"); } Ok(()) diff --git a/iroh/src/commands/doctor.rs b/iroh/src/commands/doctor.rs index 426f087b89..d4440f48b6 100644 --- a/iroh/src/commands/doctor.rs +++ b/iroh/src/commands/doctor.rs @@ -15,6 +15,7 @@ use anyhow::Context; use clap::Subcommand; use indicatif::{HumanBytes, MultiProgress, ProgressBar}; use iroh::util::{path::IrohPaths, progress::ProgressWriter}; +use iroh_base::ticket::Ticket; use iroh_net::{ defaults::DEFAULT_DERP_STUN_PORT, derp::{DerpMap, DerpMode}, @@ -875,21 +876,20 @@ fn create_secret_key(secret_key: SecretKeyOption) -> anyhow::Result { } fn inspect_ticket(ticket: &str) -> anyhow::Result<()> { - let (kind, _) = iroh::ticket::Kind::parse_prefix(ticket)?; - match kind { - iroh::ticket::Kind::Blob => { - let ticket = iroh::ticket::blob::Ticket::from_str(ticket) - .context("failed parsing blob ticket")?; - println!("Blob ticket:\n{ticket:#?}"); - } - iroh::ticket::Kind::Doc => { - let ticket = - iroh::ticket::doc::Ticket::from_str(ticket).context("failed parsing doc ticket")?; - println!("Document ticket:\n{ticket:#?}"); - } - iroh::ticket::Kind::Node => { - println!("node tickets are yet to be implemented :)"); - } + if ticket.starts_with(iroh::ticket::BlobTicket::KIND) { + let ticket = + iroh::ticket::BlobTicket::from_str(ticket).context("failed parsing blob ticket")?; + println!("Blob ticket:\n{ticket:#?}"); + } else if ticket.starts_with(iroh::ticket::DocTicket::KIND) { + let ticket = + iroh::ticket::DocTicket::from_str(ticket).context("failed parsing doc ticket")?; + println!("Document ticket:\n{ticket:#?}"); + } else if ticket.starts_with(iroh::ticket::NodeTicket::KIND) { + let ticket = + iroh::ticket::NodeTicket::from_str(ticket).context("failed parsing node ticket")?; + println!("Node ticket:\n{ticket:#?}"); + } else { + println!("Unknown ticket type"); } Ok(()) diff --git a/iroh/src/node.rs b/iroh/src/node.rs index efb943586b..38c85ed0bd 100644 --- a/iroh/src/node.rs +++ b/iroh/src/node.rs @@ -66,7 +66,7 @@ use crate::rpc_protocol::{ ProviderService, SetTagOption, }; use crate::sync_engine::{SyncEngine, SYNC_ALPN}; -use crate::ticket::blob::Ticket; +use crate::ticket::BlobTicket; const MAX_CONNECTIONS: u32 = 1024; const MAX_STREAMS: u64 = 10; @@ -708,11 +708,11 @@ impl Node { /// Return a single token containing everything needed to get a hash. /// - /// See [`Ticket`] for more details of how it can be used. - pub async fn ticket(&self, hash: Hash, format: BlobFormat) -> Result { + /// See [`BlobTicket`] for more details of how it can be used. + pub async fn ticket(&self, hash: Hash, format: BlobFormat) -> Result { // TODO: Verify that the hash exists in the db? let me = self.my_addr().await?; - Ticket::new(me, hash, format) + BlobTicket::new(me, hash, format) } /// Return the [`NodeAddr`] for this node. diff --git a/iroh/src/rpc_protocol.rs b/iroh/src/rpc_protocol.rs index 74a492b255..21fa93e82f 100644 --- a/iroh/src/rpc_protocol.rs +++ b/iroh/src/rpc_protocol.rs @@ -33,7 +33,7 @@ pub use iroh_base::rpc::{RpcError, RpcResult}; pub use iroh_bytes::{provider::AddProgress, store::ValidateProgress}; use crate::sync_engine::LiveEvent; -pub use crate::ticket::doc::Ticket as DocTicket; +pub use crate::ticket::DocTicket; /// A 32-byte key or token pub type KeyBytes = [u8; 32]; diff --git a/iroh/src/ticket.rs b/iroh/src/ticket.rs index d36734faa5..9156af8f6e 100644 --- a/iroh/src/ticket.rs +++ b/iroh/src/ticket.rs @@ -1,98 +1,5 @@ -//! This module manages the different tickets Iroh has. +//! Tickets that iroh supports +mod doc; -use iroh_base::base32; -use strum::{AsRefStr, Display, EnumIter, IntoEnumIterator}; - -pub mod blob; -pub mod doc; - -/// Kind of ticket. -#[derive(Debug, Display, PartialEq, Eq, Clone, Copy, EnumIter, AsRefStr)] -#[strum(serialize_all = "snake_case")] -pub enum Kind { - /// A blob ticket. - Blob, - /// A document ticket. - Doc, - /// A ticket for an Iroh node. - Node, -} - -impl Kind { - /// Parse the ticket prefix to obtain the [`Kind`] and remainig string. - pub fn parse_prefix(s: &str) -> Result<(Self, &str), Error> { - // we don't know the kind of ticket so try them all - for kind in Kind::iter() { - if let Some(rest) = s.strip_prefix(kind.as_ref()) { - return Ok((kind, rest)); - } - } - Err(Error::MissingKind) - } -} - -/// An error deserializing an iroh ticket. -#[derive(Debug, derive_more::Display, thiserror::Error)] -pub enum Error { - /// Found a ticket of the wrong [`Kind`]. - #[display("expected a {expected} ticket but found {found}")] - WrongKind { - /// Expected [`Kind`] of ticket. - expected: Kind, - /// Found [`Kind`] of ticket. - found: Kind, - }, - /// This does not appear to be a ticket. - #[display("not a ticket: prefix missing")] - MissingKind, - /// This looks like a ticket, but postcard deserialization failed. - #[display("deserialization failed: {_0}")] - Postcard(#[from] postcard::Error), - /// This looks like a ticket, but base32 decoding failed. - #[display("decoding failed: {_0}")] - Encoding(#[from] base32::DecodeError), - /// Verification of the deserialized bytes failed. - #[display("verification failed: {_0}")] - Verify(&'static str), -} - -trait IrohTicket: serde::Serialize + for<'de> serde::Deserialize<'de> { - /// Kind of Iroh ticket. - const KIND: Kind; - - /// Serialize to postcard bytes. - fn to_bytes(&self) -> Vec { - postcard::to_stdvec(&self).expect("postcard::to_stdvec is infallible") - } - - /// Deserialize from postcard bytes. - fn from_bytes(bytes: &[u8]) -> Result { - let ticket: Self = postcard::from_bytes(bytes)?; - ticket.verify().map_err(Error::Verify)?; - Ok(ticket) - } - - /// Verify this ticket. - fn verify(&self) -> Result<(), &'static str> { - Ok(()) - } - - /// Serialize to string. - fn serialize(&self) -> String { - let mut out = Self::KIND.to_string(); - base32::fmt_append(&self.to_bytes(), &mut out); - out - } - - /// Deserialize from a string. - fn deserialize(str: &str) -> Result { - let expected = Self::KIND; - let (found, bytes) = Kind::parse_prefix(str)?; - if expected != found { - return Err(Error::WrongKind { expected, found }); - } - let bytes = base32::parse_vec(bytes)?; - let ticket = Self::from_bytes(&bytes)?; - Ok(ticket) - } -} +pub use doc::DocTicket; +pub use iroh_net::ticket::{BlobTicket, NodeTicket}; diff --git a/iroh/src/ticket/doc.rs b/iroh/src/ticket/doc.rs index 04be149949..ea37931c13 100644 --- a/iroh/src/ticket/doc.rs +++ b/iroh/src/ticket/doc.rs @@ -1,26 +1,49 @@ //! Tickets for [`iroh-sync`] documents. +use iroh_base::ticket; use iroh_net::NodeAddr; use iroh_sync::Capability; use serde::{Deserialize, Serialize}; -use super::*; - /// Contains both a key (either secret or public) to a document, and a list of peers to join. #[derive(Serialize, Deserialize, Clone, Debug, derive_more::Display)] -#[display("{}", IrohTicket::serialize(self))] -pub struct Ticket { +#[display("{}", ticket::Ticket::serialize(self))] +pub struct DocTicket { /// either a public or private key pub capability: Capability, /// A list of nodes to contact. pub nodes: Vec, } -impl IrohTicket for Ticket { - const KIND: Kind = Kind::Doc; +/// Wire format for [`DocTicket`]. +/// +/// In the future we might have multiple variants (not versions, since they +/// might be both equally valid), so this is a single variant enum to force +/// postcard to add a discriminator. +#[derive(Serialize, Deserialize)] +enum TicketWireFormat { + Variant0(DocTicket), } -impl Ticket { +impl ticket::Ticket for DocTicket { + const KIND: &'static str = "doc"; + + fn to_bytes(&self) -> Vec { + let data = TicketWireFormat::Variant0(self.clone()); + postcard::to_stdvec(&data).expect("postcard serialization failed") + } + + fn from_bytes(bytes: &[u8]) -> Result { + let res: TicketWireFormat = postcard::from_bytes(bytes).map_err(ticket::Error::Postcard)?; + let TicketWireFormat::Variant0(res) = res; + if res.nodes.is_empty() { + return Err(ticket::Error::Verify("addressing info cannot be empty")); + } + Ok(res) + } +} + +impl DocTicket { /// Create a new doc ticket pub fn new(capability: Capability, peers: Vec) -> Self { Self { @@ -30,9 +53,53 @@ impl Ticket { } } -impl std::str::FromStr for Ticket { - type Err = Error; +impl std::str::FromStr for DocTicket { + type Err = ticket::Error; fn from_str(s: &str) -> Result { - IrohTicket::deserialize(s) + ticket::Ticket::deserialize(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iroh_base::base32; + use iroh_net::key::PublicKey; + use iroh_sync::{Capability, NamespaceId}; + use iroh_test::{assert_eq_hex, hexdump::parse_hexdump}; + + #[test] + fn test_ticket_base32() { + let node_id = PublicKey::from_bytes( + &<[u8; 32]>::try_from( + hex::decode("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let namespace_id = NamespaceId::from( + &<[u8; 32]>::try_from( + hex::decode("ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6") + .unwrap(), + ) + .unwrap(), + ); + + let ticket = DocTicket { + capability: Capability::Read(namespace_id), + nodes: vec![NodeAddr::from_parts(node_id, None, vec![])], + }; + let base32 = base32::parse_vec(ticket.to_string().strip_prefix("doc").unwrap()).unwrap(); + let expected = parse_hexdump(" + 00 # variant + 01 # capability discriminator, 1 = read + ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6 # namespace id, 32 bytes, see above + 01 # one node + ae58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b6 # node id, 32 bytes, see above + 00 # no derp url + 00 # no direct addresses + ").unwrap(); + assert_eq_hex!(base32, expected); } } diff --git a/iroh/tests/cli.rs b/iroh/tests/cli.rs index da8d58ea4e..af01054186 100644 --- a/iroh/tests/cli.rs +++ b/iroh/tests/cli.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; use bao_tree::blake3; use duct::{cmd, ReaderHandle}; use iroh::bytes::Hash; -use iroh::ticket::blob::Ticket; +use iroh::ticket::BlobTicket; use iroh::util::path::IrohPaths; use iroh_bytes::HashAndFormat; use rand::distributions::{Alphanumeric, DistString}; @@ -823,7 +823,7 @@ fn test_provide_get_loop_single(input: Input, output: Output, hash: Hash) -> Res // test provide output & get all in one ticket from stderr let ticket = match_provide_output(&mut provider, num_blobs, BlobOrCollection::Collection)?; - let ticket = Ticket::from_str(&ticket).unwrap(); + let ticket = BlobTicket::from_str(&ticket).unwrap(); let addrs = ticket .node_addr() .direct_addresses() diff --git a/iroh/tests/provide.rs b/iroh/tests/provide.rs index c93f71a390..f9bec0190b 100644 --- a/iroh/tests/provide.rs +++ b/iroh/tests/provide.rs @@ -8,7 +8,10 @@ use std::{ use anyhow::{anyhow, Context, Result}; use bytes::Bytes; use futures::FutureExt; -use iroh::node::{Builder, Event, Node}; +use iroh::{ + dial::Options, + node::{Builder, Event, Node}, +}; use iroh_net::{key::SecretKey, NodeId}; use quic_rpc::transport::misc::DummyServerEndpoint; use rand::RngCore; @@ -502,10 +505,12 @@ async fn test_run_ticket() { tokio::time::timeout(Duration::from_secs(10), async move { let request = GetRequest::all(hash); run_collection_get_request( - ticket.as_get_options( - SecretKey::generate(), - Some(iroh_net::defaults::default_derp_map()), - ), + Options { + secret_key: SecretKey::generate(), + peer: ticket.node_addr().clone(), + keylog: false, + derp_map: Some(iroh_net::defaults::default_derp_map()), + }, request, ) .await