From a1074c00276b3f009444a9af0ee06a0f2d08d721 Mon Sep 17 00:00:00 2001 From: Gabi Moldovan Date: Thu, 16 Mar 2023 13:51:29 +0000 Subject: [PATCH] Add builder for encoding hidden service descriptors. This introduces the `NetdocBuilder` trait described in `netdoc-builder.md` and a new `tor-netdoc::doc::hsdesc::build` module, which exports the `HsDescBuilder`. Hidden services will use `HsDescBuilder` to build and encode hidden service descriptors. There are several TODOs in the code that I'm planning to address separately. Partially addresses #745. Signed-off-by: Gabriela Moldovan --- Cargo.lock | 2 + crates/tor-hscrypto/src/lib.rs | 11 +- crates/tor-llcrypto/Cargo.toml | 1 + crates/tor-llcrypto/src/util/ct.rs | 2 +- crates/tor-netdoc/Cargo.toml | 5 +- crates/tor-netdoc/src/build.rs | 28 +- crates/tor-netdoc/src/doc/hsdesc.rs | 42 +- crates/tor-netdoc/src/doc/hsdesc/build.rs | 384 ++++++++++++++++++ .../tor-netdoc/src/doc/hsdesc/build/inner.rs | 384 ++++++++++++++++++ .../tor-netdoc/src/doc/hsdesc/build/middle.rs | 239 +++++++++++ .../tor-netdoc/src/doc/hsdesc/build/outer.rs | 183 +++++++++ crates/tor-netdoc/src/doc/hsdesc/desc_enc.rs | 8 +- crates/tor-netdoc/src/doc/hsdesc/inner.rs | 2 +- crates/tor-netdoc/src/doc/hsdesc/middle.rs | 17 +- crates/tor-netdoc/src/doc/hsdesc/outer.rs | 14 +- crates/tor-netdoc/src/lib.rs | 2 +- 16 files changed, 1280 insertions(+), 44 deletions(-) create mode 100644 crates/tor-netdoc/src/doc/hsdesc/build.rs create mode 100644 crates/tor-netdoc/src/doc/hsdesc/build/inner.rs create mode 100644 crates/tor-netdoc/src/doc/hsdesc/build/middle.rs create mode 100644 crates/tor-netdoc/src/doc/hsdesc/build/outer.rs diff --git a/Cargo.lock b/Cargo.lock index f5f6af52e..c150820a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4075,6 +4075,7 @@ dependencies = [ "cipher", "ctr", "curve25519-dalek", + "derive_more", "digest 0.10.6", "ed25519-dalek", "getrandom 0.2.8", @@ -4146,6 +4147,7 @@ dependencies = [ "base64ct", "bitflags 2.0.0", "cipher", + "derive_builder_fork_arti", "derive_more", "digest 0.10.6", "educe", diff --git a/crates/tor-hscrypto/src/lib.rs b/crates/tor-hscrypto/src/lib.rs index 83a25e62a..dee06bbc6 100644 --- a/crates/tor-hscrypto/src/lib.rs +++ b/crates/tor-hscrypto/src/lib.rs @@ -64,7 +64,16 @@ pub struct Subcredential([u8; 32]); /// There can be gaps in this numbering. A descriptor with a higher-valued /// revision counter supersedes one with a lower revision counter. #[derive( - Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, derive_more::From, derive_more::Into, + Copy, + Clone, + Debug, + Ord, + PartialOrd, + Eq, + PartialEq, + derive_more::Deref, + derive_more::From, + derive_more::Into, )] pub struct RevisionCounter(u64); diff --git a/crates/tor-llcrypto/Cargo.toml b/crates/tor-llcrypto/Cargo.toml index 5ad3af1d6..4118ec9a8 100644 --- a/crates/tor-llcrypto/Cargo.toml +++ b/crates/tor-llcrypto/Cargo.toml @@ -31,6 +31,7 @@ base64ct = "1.5.1" cipher = { version = "0.4.3", optional = true, features = ["zeroize"] } ctr = { version = "0.9", features = ["zeroize"] } curve25519-dalek = "3.2" +derive_more = "0.99.3" digest = "0.10.0" ed25519-dalek = { version = "1", features = ["batch"] } hex = "0.4" diff --git a/crates/tor-llcrypto/src/util/ct.rs b/crates/tor-llcrypto/src/util/ct.rs index 4770f6a4c..01c8adddb 100644 --- a/crates/tor-llcrypto/src/util/ct.rs +++ b/crates/tor-llcrypto/src/util/ct.rs @@ -15,7 +15,7 @@ use zeroize::Zeroize; /// (The decision to avoid implementing `Deref`/`DerefMut` is deliberate.) #[allow(renamed_and_removed_lints)] // TODO Remove @ MSRV 1.68 #[allow(clippy::derive_hash_xor_eq)] // TODO Rename @ MSRV 1.68 -#[derive(Clone, Copy, Debug, Hash, Zeroize)] +#[derive(Clone, Copy, Debug, Hash, Zeroize, derive_more::Deref)] pub struct CtByteArray([u8; N]); impl ConstantTimeEq for CtByteArray { diff --git a/crates/tor-netdoc/Cargo.toml b/crates/tor-netdoc/Cargo.toml index 4ad93c115..5b34c077e 100644 --- a/crates/tor-netdoc/Cargo.toml +++ b/crates/tor-netdoc/Cargo.toml @@ -33,11 +33,11 @@ routerdesc = [] # Enable the "ns consensus" document type, which some relays cache and serve. ns_consensus = [] -# Client-side and service-side support for onion services. +# Client-side and service-side support for onion services. # Experimental: not covered by semver guarantees. # TODO hs: mark these as part of "full" once they are done and stable. hs-client = ["hs-common"] -hs-service = ["hs-common", "rand"] +hs-service = ["hs-common", "rand", "tor-cert/encode"] hs-common = ["tor-hscrypto", "tor-linkspec", "tor-units"] # Testing only : expose code to parse inner layers of onion service descriptors. hsdesc-inner-docs = ["visibility"] @@ -64,6 +64,7 @@ arrayref = "0.3" base64ct = { version = "1.5.1", features = ["alloc"] } bitflags = "2" cipher = { version = "0.4.1", features = ["zeroize"] } +derive_builder = { version = "0.11.2", package = "derive_builder_fork_arti" } derive_more = "0.99.3" digest = "0.10.0" educe = "0.4.6" diff --git a/crates/tor-netdoc/src/build.rs b/crates/tor-netdoc/src/build.rs index 45db97d92..257519d9b 100644 --- a/crates/tor-netdoc/src/build.rs +++ b/crates/tor-netdoc/src/build.rs @@ -27,7 +27,9 @@ use std::time::SystemTime; use base64ct::{Base64, Encoding}; use humantime::format_rfc3339; use tor_bytes::EncodeError; +use tor_cert::Ed25519Cert; use tor_error::{internal, into_internal, Bug}; +use tor_llcrypto::pk::ed25519; use crate::parse::keyword::Keyword; use crate::parse::tokenize::tag_keywords_ok; @@ -38,6 +40,7 @@ use crate::parse::tokenize::tag_keywords_ok; /// for clarity in function signatures etc. // // TODO hs: Is this type carrying its weight, or should we just replace it with String ? +#[derive(Debug)] pub struct NetdocText { /// The actual document text: String, @@ -52,6 +55,19 @@ impl Deref for NetdocText { } } +impl NetdocText { + /// Convert self into an identical `NetdocText` of a different kind. + /// + /// Useful for marking the `NetdocText` as having been produced using a different builder (for + /// example, in cases where builders leverage other builders to create the final text). + pub(crate) fn into_kind(self) -> NetdocText { + NetdocText { + text: self.text, + kind: PhantomData, + } + } +} + /// Encoder, representing a partially-built document /// /// # Example @@ -210,7 +226,7 @@ impl NetdocEncoder { } /// Build the document into textual form - pub(crate) fn finish(self) -> Result, Bug> { + pub(crate) fn finish(self) -> Result, Bug> { let text = self.built?; Ok(NetdocText { text, @@ -369,6 +385,14 @@ impl Drop for ItemEncoder<'_> { } } +/// A trait for building and signing netdocs. +pub trait NetdocBuilder { + /// Build the document into textual form. + fn build_sign(self) -> Result, EncodeError> + where + Self: Sized; +} + #[cfg(test)] mod test { // @@ begin test lint list maintained by maint/add_warning @@ @@ -428,7 +452,7 @@ qiBHRBGbtkF/Re5pb438HC/CGyuujp43oZ3CUYosJOfY/X+sD0aVAgMBAAE"; .item(ACK::DIR_KEY_CERTIFICATION) .object("SIGNATURE", []); - let doc = encode.finish().unwrap(); + let doc = encode.finish::().unwrap(); eprintln!("{}", &*doc); assert_eq!( &*doc, diff --git a/crates/tor-netdoc/src/doc/hsdesc.rs b/crates/tor-netdoc/src/doc/hsdesc.rs index 57890014a..91c63d7a8 100644 --- a/crates/tor-netdoc/src/doc/hsdesc.rs +++ b/crates/tor-netdoc/src/doc/hsdesc.rs @@ -11,34 +11,37 @@ #![allow(dead_code, unused_variables, clippy::missing_panics_doc)] // TODO hs: remove. mod desc_enc; +mod build; mod inner; mod middle; mod outer; -use std::time::SystemTime; +pub use desc_enc::DecryptionError; use crate::{ParseErrorKind as EK, Result}; -pub use desc_enc::DecryptionError; -use smallvec::SmallVec; -use tor_checkable::{ - signed::{self, SignatureGated}, - timed::{self, TimerangeBound}, -}; -use tor_hscrypto::{ - pk::{ - HsBlindId, HsClientDescEncKey, HsClientDescEncSecretKey, HsIntroPtSessionIdKey, - HsSvcNtorKey, - }, - RevisionCounter, Subcredential, + +use tor_checkable::signed::{self, SignatureGated}; +use tor_checkable::timed::{self, TimerangeBound}; +use tor_hscrypto::pk::{ + HsBlindId, HsClientDescEncKey, HsClientDescEncSecretKey, HsIntroPtSessionIdKey, HsSvcNtorKey, }; +use tor_hscrypto::{RevisionCounter, Subcredential}; use tor_linkspec::UnparsedLinkSpec; use tor_llcrypto::pk::curve25519; use tor_units::IntegerMinutes; +use smallvec::SmallVec; + +use std::time::SystemTime; + #[cfg(feature = "hsdesc-inner-docs")] #[cfg_attr(docsrs, doc(cfg(feature = "hsdesc-inner-docs")))] pub use {inner::HsDescInner, middle::HsDescMiddle, outer::HsDescOuter}; +#[cfg(feature = "hs-service")] +#[cfg_attr(docsrs, doc(cfg(feature = "hs-service")))] +pub use build::HsDescBuilder; + /// Metadata about an onion service descriptor, as stored at an HsDir. /// /// This object is parsed from the outermost document of an onion service @@ -113,9 +116,11 @@ pub struct HsDesc { /// A type of authentication that is required when introducing to an onion /// service. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum IntroAuthType { +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq, derive_more::Display)] +pub enum IntroAuthType { /// Ed25519 authentication is required. + #[display(fmt = "ed25519")] Ed25519, } @@ -300,11 +305,6 @@ impl StoredHsDescMeta { } } -// TODO hs: Define a HsDescBuilder structure, but it should not create an HsDesc directly. -// Instead, it should make something that is _like_ an HsDesc but with extra client keys, -// full certificates and so on. Then, define a function taking the correct set of private -// keys and using them to encode, encrypt, and sign the built HsDesc. - #[cfg(test)] mod test { // @@ begin test lint list maintained by maint/add_warning @@ @@ -316,7 +316,7 @@ mod test { #![allow(clippy::single_char_pattern)] #![allow(clippy::unwrap_used)] #![allow(clippy::unchecked_duration_subtraction)] - //! + //! use std::time::Duration; use super::*; diff --git a/crates/tor-netdoc/src/doc/hsdesc/build.rs b/crates/tor-netdoc/src/doc/hsdesc/build.rs new file mode 100644 index 000000000..72edb0b4d --- /dev/null +++ b/crates/tor-netdoc/src/doc/hsdesc/build.rs @@ -0,0 +1,384 @@ +//! Hidden service descriptor encoding. + +mod inner; +mod middle; +mod outer; + +use crate::doc::hsdesc::{IntroAuthType, IntroPointDesc}; +use crate::{NetdocBuilder, NetdocText}; +use tor_bytes::EncodeError; +use tor_error::into_bad_api_usage; +use tor_hscrypto::{pk::HsSvcDescEncKey, RevisionCounter, Subcredential}; +use tor_llcrypto::pk::ed25519; +use tor_units::IntegerMinutes; + +use derive_builder::Builder; +use smallvec::SmallVec; + +use std::borrow::{Borrow, Cow}; +use std::time::SystemTime; + +use self::inner::HsDescInnerBuilder; +use self::middle::HsDescMiddleBuilder; +use self::outer::HsDescOuterBuilder; + +use super::desc_enc::{HsDescEncNonce, HsDescEncryption, HS_DESC_ENC_NONCE_LEN}; +use super::middle::AuthClient; + +/// A builder for encoding hidden service descriptors. +/// +/// TODO hs: a comprehensive usage example. +#[derive(Builder)] +#[builder(public, derive(Debug), pattern = "owned", build_fn(vis = ""))] +struct HsDesc<'a> { + /// The blinded hidden service signing keys used to sign descriptor signing keys + /// (KP_hs_blind_id, KS_hs_blind_id). + /// + /// TODO hs: we might want to use a HsBlindIdKey/HsBlindIdSecretKey keypair instead. However, + /// it looks like that will require some work: Ed25519CertConstructor::encode_and_sign needs to + /// be changed to support types other than ed25519::Keypairs (i.e. skey will need to be of type + /// S: Signer + PublicKey, where PublicKey is a trait with a .pk() function that returns an + /// ed25519::PublicKey). + blinded_id: &'a ed25519::Keypair, + /// The short-term descriptor signing key (KP_hs_desc_sign, KS_hs_desc_sign). + hs_desc_sign: &'a ed25519::Keypair, + /// The expiration time of the descriptor signing key certificate. + hs_desc_sign_cert_expiry: SystemTime, + /// A list of recognized CREATE handshakes that this onion service supports. + // TODO hs: this should probably be a caret enum, not an integer + create2_formats: &'a [u32], + /// A list of authentication types that this onion service supports. + auth_required: Option>, + /// If true, this a "single onion service" and is not trying to keep its own location private. + is_single_onion_service: bool, + /// One or more introduction points used to contact the onion service. + intro_points: &'a [IntroPointDesc], + /// The expiration time of an introduction point authentication key certificate. + intro_auth_key_cert_expiry: SystemTime, + /// The expiration time of an introduction point encryption key certificate. + intro_enc_key_cert_expiry: SystemTime, + /// Client authorization parameters, if client authentication is enabled. If set to `None`, + /// client authentication is disabled. + client_auth: Option<&'a ClientAuth>, + /// The lifetime of this descriptor, in minutes. + /// + /// This doesn't actually list the starting time or the end time for the + /// descriptor: presumably, because we didn't want to leak the onion + /// service's view of the wallclock. + lifetime: IntegerMinutes, + /// A revision counter to tell whether this descriptor is more or less recent + /// than another one for the same blinded ID. + revision_counter: RevisionCounter, + /// The "subcredential" of the onion service. + subcredential: Subcredential, +} + +/// Client authorization parameters. +// TODO HS this ought to go away from the public API (see TODO below), or use a builder? +#[derive(Clone, Debug)] +pub struct ClientAuth { + /// The ephemeral x25519 ephemeral public key generated by the hidden service + /// (`KP_hss_desc_enc`). + pub ephemeral_key: HsSvcDescEncKey, + /// One or more authorized clients, and the key exchange information that + /// they use to compute shared keys for decrypting the encryption layer. + /// + /// If client authorization is disabled (i.e. this array is empty), the resulting middle + /// document will contain a single auth-client client populated with random values. + /// + /// TODO hs: currently it is the responsibility of the hidden service to create an `AuthClient` + /// for each authorized client. Instead of using `Vec` here, it would be better to + /// just have a list of public keys (one for each authorized client), and let + /// `HsDescMiddleBuilder` create the underlying `AuthClient`. + pub auth_clients: Vec, + /// The value of `N_hs_desc_enc` descriptor_cookie key generated by the hidden service. + /// + /// TODO hs: Do we even need this field? This is presumed to be randomly generated for each + /// descriptor by the hidden service, but since it's random, we might as well let the + /// descriptor builder generate it. + pub descriptor_cookie: [u8; HS_DESC_ENC_NONCE_LEN], +} + +impl<'a> NetdocBuilder for HsDescBuilder<'a> { + fn build_sign(self) -> Result, EncodeError> { + /// The superencrypted field must be padded to the nearest multiple of 10k bytes + /// + /// rend-spec-v3 2.5.1.1 + const SUPERENCRYPTED_ALIGN: usize = 10 * (1 << 10); + + let hs_desc = self + .build() + .map_err(into_bad_api_usage!("the HsDesc could not be built"))?; + + // Construct the second layer plaintext. This is the unencrypted value of the "encrypted" + // field. + let encrypted_plaintext = HsDescInnerBuilder::default() + .hs_desc_sign(hs_desc.hs_desc_sign) + .create2_formats(hs_desc.create2_formats) + .auth_required(hs_desc.auth_required.as_ref()) + .is_single_onion_service(hs_desc.is_single_onion_service) + .intro_points(hs_desc.intro_points) + .intro_auth_key_cert_expiry(hs_desc.intro_auth_key_cert_expiry) + .intro_enc_key_cert_expiry(hs_desc.intro_enc_key_cert_expiry) + .build_sign()?; + + let desc_enc_nonce = hs_desc + .client_auth + .as_ref() + .map(|client_auth| client_auth.descriptor_cookie.into()); + + // Encrypt the second layer to obtain the "encrypted" field + let encrypted = hs_desc.encrypt_field( + encrypted_plaintext.as_bytes(), + desc_enc_nonce.as_ref(), + b"hsdir-encrypted-data", + ); + + // Construct the first layer plaintext. This is the unencrypted value of the + // "superencrypted" field. + let superencrypted_plaintext = HsDescMiddleBuilder::default() + .client_auth(hs_desc.client_auth) + .encrypted(encrypted) + .build_sign()?; + + // Section 2.5.1.1. of rend-spec-v3: before encryption, pad the plaintext to the nearest + // multiple of 10k bytes + let superencrypted_plaintext = + pad_with_zero_to_align(superencrypted_plaintext.as_bytes(), SUPERENCRYPTED_ALIGN); + + // Encrypt the first layer to obtain the "superencrypted" field. + let superencrypted = hs_desc.encrypt_field( + superencrypted_plaintext.borrow(), + // desc_enc_nonce is absent when handling the superencryption layer (2.5.1.1). + None, + b"hsdir-superencrypted-data", + ); + + // Finally, build the hidden service descriptor. + HsDescOuterBuilder::default() + .blinded_id(hs_desc.blinded_id) + .hs_desc_sign(hs_desc.hs_desc_sign) + .hs_desc_sign_cert_expiry(hs_desc.hs_desc_sign_cert_expiry) + .lifetime(hs_desc.lifetime) + .revision_counter(hs_desc.revision_counter) + .superencrypted(superencrypted) + .build_sign() + .map(NetdocText::into_kind) + } +} + +impl<'a> HsDesc<'a> { + /// Encrypt the specified plaintext using the algorithm described in section + /// `[HS-DESC-ENCRYPTION-KEYS]` of rend-spec-v3.txt. + fn encrypt_field( + &self, + plaintext: &[u8], + desc_enc_nonce: Option<&HsDescEncNonce>, + string_const: &[u8], + ) -> Vec { + let encrypt = HsDescEncryption { + blinded_id: &ed25519::Ed25519Identity::from(self.blinded_id.public).into(), + desc_enc_nonce, + subcredential: &self.subcredential, + revision: self.revision_counter, + string_const, + }; + + encrypt.encrypt(&mut rand::thread_rng(), plaintext) + } +} + +/// Pad `v` with zeroes to the next multiple of `alignment`. +fn pad_with_zero_to_align(v: &[u8], alignment: usize) -> Cow<[u8]> { + let padding = (alignment - (v.len() % alignment)) % alignment; + + if padding > 0 { + let padded = v + .iter() + .copied() + .chain(std::iter::repeat(0).take(padding)) + .collect::>(); + + Cow::Owned(padded) + } else { + // No need to pad. + Cow::Borrowed(v) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_duration_subtraction)] + //! + + use std::time::Duration; + + use super::*; + use crate::doc::hsdesc::test::TEST_SUBCREDENTIAL; + use crate::doc::hsdesc::{EncryptedHsDesc, HsDesc as HsDescDecoder}; + use tor_basic_utils::test_rng::testing_rng; + use tor_checkable::{SelfSigned, Timebound}; + use tor_hscrypto::time::TimePeriod; + use tor_linkspec::UnparsedLinkSpec; + use tor_llcrypto::pk::curve25519; + use tor_llcrypto::util::rand_compat::RngCompatExt; + + // TODO: move the test helpers and constants to a separate module and make them more broadly + // available if necessary. + + pub(super) const TEST_CURVE25519_PUBLIC1: [u8; 32] = [ + 182, 113, 33, 95, 205, 245, 236, 169, 54, 55, 168, 104, 105, 203, 2, 43, 72, 171, 252, 178, + 132, 220, 55, 15, 129, 137, 67, 35, 147, 138, 122, 8, + ]; + + pub(super) const TEST_CURVE25519_PUBLIC2: [u8; 32] = [ + 115, 163, 198, 37, 3, 64, 168, 156, 114, 124, 46, 142, 233, 91, 239, 29, 207, 240, 128, + 202, 208, 112, 170, 247, 82, 46, 233, 6, 251, 246, 117, 113, + ]; + + pub(super) const TEST_ED_KEYPAIR: [u8; 64] = [ + 164, 100, 212, 102, 173, 112, 229, 145, 212, 233, 189, 78, 124, 100, 245, 20, 102, 4, 108, + 203, 245, 104, 234, 23, 9, 111, 238, 233, 53, 88, 41, 157, 236, 25, 168, 191, 85, 102, 73, + 11, 12, 101, 80, 225, 230, 28, 9, 208, 127, 219, 229, 239, 42, 166, 147, 232, 55, 206, 57, + 210, 10, 215, 54, 60, + ]; + + // Not a real cookie, just a bunch of ones. + pub(super) const TEST_DESCRIPTOR_COOKIE: [u8; HS_DESC_ENC_NONCE_LEN] = + [1; HS_DESC_ENC_NONCE_LEN]; + + /// Expect `err` to be a `Bug`, and return its string representation. + /// + /// # Panics + /// + /// Panics if `err` is not a `Bug`. + pub(super) fn expect_bug(err: EncodeError) -> String { + match err { + EncodeError::Bug(b) => b.to_string(), + EncodeError::BadLengthValue => panic!("expected Bug, got BadLengthValue"), + _ => panic!("expected Bug, got unknown error"), + } + } + + /// Some tests require determinism, so always return the same keypair. + pub(super) fn test_ed25519_keypair() -> ed25519::Keypair { + ed25519::Keypair::from_bytes(&TEST_ED_KEYPAIR).unwrap() + } + + /// Create a new ed25519 keypair. + pub(super) fn create_ed25519_keypair() -> ed25519::Keypair { + let mut rng = testing_rng().rng_compat(); + ed25519::Keypair::generate(&mut rng) + } + + /// Create a new curve25519 public key. + pub(super) fn create_curve25519_pk() -> curve25519::PublicKey { + let rng = testing_rng().rng_compat(); + let ephemeral_key = curve25519::EphemeralSecret::new(rng); + (&ephemeral_key).into() + } + + pub(super) fn test_intro_point_descriptor( + link_specifiers: Vec, + ) -> IntroPointDesc { + IntroPointDesc { + link_specifiers, + ipt_ntor_key: curve25519::PublicKey::from(TEST_CURVE25519_PUBLIC1), + ipt_sid_key: test_ed25519_keypair().public.into(), + svc_ntor_key: curve25519::PublicKey::from(TEST_CURVE25519_PUBLIC2).into(), + } + } + + #[test] + fn encode_decode() { + let blinded_id = test_ed25519_keypair(); + let hs_desc_sign = test_ed25519_keypair(); + let period = TimePeriod::new( + humantime::parse_duration("24 hours").unwrap(), + humantime::parse_rfc3339("2023-02-09T12:00:00Z").unwrap(), + humantime::parse_duration("12 hours").unwrap(), + ) + .unwrap(); + + // TODO: not a valid subcredential + let subcredential = TEST_SUBCREDENTIAL.into(); + + let expiry = SystemTime::now() + Duration::from_secs(60 * 60); + let intro_points = vec![IntroPointDesc { + link_specifiers: vec![UnparsedLinkSpec::new(0, vec![1, 2, 3, 4])], + ipt_ntor_key: create_curve25519_pk(), + ipt_sid_key: create_ed25519_keypair().public.into(), + svc_ntor_key: create_curve25519_pk().into(), + }]; + + // Build and encode a new descriptor: + let encoded_desc = HsDescBuilder::default() + .blinded_id(&blinded_id) + .hs_desc_sign(&hs_desc_sign) + .hs_desc_sign_cert_expiry(expiry) + .create2_formats(&[1, 2]) + .auth_required(None) + .is_single_onion_service(true) + .intro_points(&intro_points) + .intro_auth_key_cert_expiry(expiry) + .intro_enc_key_cert_expiry(expiry) + .client_auth(None) + .lifetime(100.into()) + .revision_counter(2.into()) + .subcredential(subcredential) + .build_sign() + .unwrap(); + + let id = ed25519::Ed25519Identity::from(&blinded_id.public); + // Now decode it + let enc_desc: EncryptedHsDesc = HsDescDecoder::parse(&encoded_desc, &id.into()) + .unwrap() + .check_signature() + .unwrap() + .check_valid_at(&humantime::parse_rfc3339("2023-01-23T15:00:00Z").unwrap()) + .unwrap(); + + let desc = enc_desc + .decrypt(&subcredential, None) + .unwrap() + .check_valid_at(&humantime::parse_rfc3339("2023-01-23T15:00:00Z").unwrap()) + .unwrap() + .check_signature() + .unwrap(); + + // Now encode it again and check the result is identical to the original + let reencoded_desc = HsDescBuilder::default() + .blinded_id(&blinded_id) + .hs_desc_sign(&hs_desc_sign) + .hs_desc_sign_cert_expiry(expiry) + // create2_formats is hard-coded rather than extracted from desc, because + // create2_formats is ignored while parsing + .create2_formats(&[1, 2]) + .auth_required(None) + .is_single_onion_service(desc.is_single_onion_service) + .intro_points(&intro_points) + .intro_auth_key_cert_expiry(expiry) + .intro_enc_key_cert_expiry(expiry) + .client_auth(None) + .lifetime(desc.idx_info.lifetime) + .revision_counter(desc.idx_info.revision) + .subcredential(subcredential) + .build_sign() + .unwrap(); + + // TODO: a more useful assertion. The two won't be identical unless client auth is enabled + // (if client auth is disabled, the builder generates a new desc-auth-ephemeral-key and a + // client-auth line filled with random values, which will be different for each descriptor). + //assert_eq!(&*encoded_desc, &*reencoded_desc); + } + + // TODO hs: encode a descriptor with client auth enabled +} diff --git a/crates/tor-netdoc/src/doc/hsdesc/build/inner.rs b/crates/tor-netdoc/src/doc/hsdesc/build/inner.rs new file mode 100644 index 000000000..e1fdaa483 --- /dev/null +++ b/crates/tor-netdoc/src/doc/hsdesc/build/inner.rs @@ -0,0 +1,384 @@ +//! Functionality for encoding the inner document of an onion service descriptor. +//! +//! NOTE: `HsDescInnerBuilder` is a private helper for building hidden service descriptors, and is +//! not meant to be used directly. Hidden services will use `HsDescBuilder` to build and encode +//! hidden service descriptors. + +use crate::build::NetdocEncoder; +use crate::doc::hsdesc::inner::HsInnerKwd; +use crate::{NetdocBuilder, NetdocText}; + +use tor_bytes::{EncodeError, Writer}; +use tor_cert::{CertType, CertifiedKey, Ed25519Cert}; +use tor_error::{bad_api_usage, into_bad_api_usage}; +use tor_llcrypto::pk::ed25519; +use tor_llcrypto::pk::keymanip::convert_curve25519_to_ed25519_public; + +use base64ct::{Base64, Encoding}; + +mod private { + //! A private module that defines the `HsDescInner` structure. + //! + //! TODO: while you can use derive_builder to create a private "built" struct with a public + //! builder (by making the struct private and setting `build_fn(vis = "pub(super)")`), it does + //! not support finer grained visibility for the builder struct. This means we cannot make the + //! builder struct `pub(super)` while keeping the "built" struct private (which is what we want + //! here, as this builder only exists to serve as a helper for the top-level `HsDescBuilder`). + //! + //! This module was created as a workaround to this limitation. + + use std::time::SystemTime; + + use derive_builder::Builder; + use smallvec::SmallVec; + use tor_llcrypto::pk::ed25519; + + use crate::doc::hsdesc::{IntroAuthType, IntroPointDesc}; + + /// The representation of the inner document of an onion service descriptor. + /// + /// The plaintext format of this document is described in section 2.5.2.2. of rend-spec-v3. + #[derive(Builder)] + #[builder(public, derive(Debug), pattern = "owned", build_fn(vis = "pub(super)"))] + pub(super) struct HsDescInner<'a> { + /// The descriptor signing key. + pub(super) hs_desc_sign: &'a ed25519::Keypair, + /// A list of recognized CREATE handshakes that this onion service supports. + // TODO hs: this should probably be a caret enum, not an integer + pub(super) create2_formats: &'a [u32], + /// A list of authentication types that this onion service supports. + pub(super) auth_required: Option<&'a SmallVec<[IntroAuthType; 2]>>, + /// If true, this a "single onion service" and is not trying to keep its own location private. + pub(super) is_single_onion_service: bool, + /// One or more introduction points used to contact the onion service. + pub(super) intro_points: &'a [IntroPointDesc], + /// The expiration time of an introduction point authentication key certificate. + pub(super) intro_auth_key_cert_expiry: SystemTime, + /// The expiration time of an introduction point encryption key certificate. + pub(super) intro_enc_key_cert_expiry: SystemTime, + } +} + +pub(super) use private::HsDescInnerBuilder; + +use private::HsDescInner; + +impl<'a> NetdocBuilder for HsDescInnerBuilder<'a> { + fn build_sign(self) -> Result, EncodeError> { + use HsInnerKwd::*; + + let HsDescInner { + hs_desc_sign, + create2_formats, + auth_required, + is_single_onion_service, + intro_points, + intro_auth_key_cert_expiry, + intro_enc_key_cert_expiry, + } = self + .build() + .map_err(into_bad_api_usage!("the HsDescInner could not be built"))?; + + let mut encoder = NetdocEncoder::new(); + + { + let mut create2_formats_enc = encoder.item(CREATE2_FORMATS); + for fmt in create2_formats { + create2_formats_enc = create2_formats_enc.arg(&fmt); + } + } + + { + if let Some(auth_required) = auth_required { + let mut auth_required_enc = encoder.item(INTRO_AUTH_REQUIRED); + for auth in auth_required { + auth_required_enc = auth_required_enc.arg(&auth.to_string()); + } + } + } + + if is_single_onion_service { + encoder.item(SINGLE_ONION_SERVICE); + } + + for intro_point in intro_points { + // rend-spec-v3 0.4. "Protocol building blocks [BUILDING-BLOCKS]": the number of link + // specifiers (NPSEC) must fit in a single byte. + let nspec: u8 = intro_point + .link_specifiers + .len() + .try_into() + .map_err(into_bad_api_usage!("Too many link specifiers."))?; + + let mut link_specifiers = vec![]; + link_specifiers.write_u8(nspec); + for link_spec in &intro_point.link_specifiers { + link_specifiers.write(link_spec)?; + } + + encoder + .item(INTRODUCTION_POINT) + .arg(&Base64::encode_string(&link_specifiers)); + encoder + .item(ONION_KEY) + .arg(&"ntor") + .arg(&Base64::encode_string(&intro_point.ipt_ntor_key.to_bytes())); + + // For compatibility with c-tor, the introduction point authentication key is signed by + // the descriptor signing key. + let signed_auth_key = Ed25519Cert::constructor() + .cert_type(CertType::HS_IP_V_SIGNING) + .expiration(intro_auth_key_cert_expiry) + .signing_key(ed25519::Ed25519Identity::from(hs_desc_sign.public)) + .cert_key(CertifiedKey::Ed25519((*intro_point.ipt_sid_key).into())) + .encode_and_sign(hs_desc_sign) + .map_err(into_bad_api_usage!("failed to sign the intro auth key"))?; + + encoder + .item(AUTH_KEY) + .object("ED25519 CERT", signed_auth_key); + + // "The key is a base64 encoded curve25519 public key used to encrypt the introduction + // request to service. (`KP_hss_ntor`)" + // + // TODO hs: The spec allows for multiple enc-key lines, but we currently only ever encode + // a single one. + encoder + .item(ENC_KEY) + .arg(&"ntor") + .arg(&Base64::encode_string( + &intro_point.svc_ntor_key.as_bytes()[..], + )); + + // The subject key is the the ed25519 equivalent of the svc_ntor_key curve25519 public + // encryption key. + + // TODO hs: should the sign bit be 0 or 1? + let signbit = 0; + let ed_svc_ntor_key = + convert_curve25519_to_ed25519_public(&intro_point.svc_ntor_key, signbit) + .ok_or_else(|| { + bad_api_usage!("failed to convert curve25519 pk to ed25519 pk") + })?; + + // For compatibility with c-tor, the encryption key is signed with the descriptor + // signing key. + let signed_enc_key = Ed25519Cert::constructor() + .cert_type(CertType::HS_IP_CC_SIGNING) + .expiration(intro_enc_key_cert_expiry) + .signing_key(ed25519::Ed25519Identity::from(hs_desc_sign.public)) + .cert_key(CertifiedKey::Ed25519(ed25519::Ed25519Identity::from( + &ed_svc_ntor_key, + ))) + .encode_and_sign(hs_desc_sign) + .map_err(into_bad_api_usage!( + "failed to sign the intro encryption key" + ))?; + + encoder + .item(ENC_KEY_CERT) + .object("ED25519 CERT", signed_enc_key); + } + + encoder.finish().map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_duration_subtraction)] + //! + + use super::*; + use crate::doc::hsdesc::build::test::{ + expect_bug, test_ed25519_keypair, test_intro_point_descriptor, + }; + use crate::doc::hsdesc::IntroAuthType; + + use smallvec::SmallVec; + use std::time::UNIX_EPOCH; + use tor_linkspec::UnparsedLinkSpec; + + #[test] + fn inner_hsdesc_no_intro_auth() { + let hs_desc_sign = test_ed25519_keypair(); + + // A descriptor for a "single onion service" + let hs_desc = HsDescInnerBuilder::default() + .hs_desc_sign(&hs_desc_sign) + .create2_formats(&[1234]) + .auth_required(None) + .is_single_onion_service(true) + .intro_points(&[]) // no introduction points + .intro_auth_key_cert_expiry(UNIX_EPOCH) + .intro_enc_key_cert_expiry(UNIX_EPOCH) + .build_sign() + .unwrap(); + + assert_eq!(&*hs_desc, "create2-formats 1234\nsingle-onion-service\n"); + + // A descriptor for a location-hidden service + let hs_desc = HsDescInnerBuilder::default() + .hs_desc_sign(&hs_desc_sign) + .create2_formats(&[1234]) + .auth_required(None) + .is_single_onion_service(false) + .intro_points(&[]) // no introduction points + .intro_auth_key_cert_expiry(UNIX_EPOCH) + .intro_enc_key_cert_expiry(UNIX_EPOCH) + .build_sign() + .unwrap(); + + assert_eq!(&*hs_desc, "create2-formats 1234\n"); + + let link_specs1 = vec![UnparsedLinkSpec::new(0, vec![1, 2, 3, 4])]; + let link_specs2 = vec![UnparsedLinkSpec::new(5, vec![6, 7, 8, 9])]; + let link_specs3 = vec![UnparsedLinkSpec::new(10, vec![11, 12, 13, 14])]; + + let intros = vec![ + test_intro_point_descriptor(link_specs1), + test_intro_point_descriptor(link_specs2), + test_intro_point_descriptor(link_specs3), + ]; + + // A descriptor for a location-hidden service with 3 introduction points + let hs_desc = HsDescInnerBuilder::default() + .hs_desc_sign(&hs_desc_sign) + .create2_formats(&[1234, 32, 23]) + .auth_required(None) + .is_single_onion_service(false) + .intro_points(&intros) + .intro_auth_key_cert_expiry(UNIX_EPOCH) + .intro_enc_key_cert_expiry(UNIX_EPOCH) + .build_sign() + .unwrap(); + + assert_eq!( + &*hs_desc, + r#"create2-formats 1234 32 23 +introduction-point AQAEAQIDBA== +onion-key ntor tnEhX8317Kk2N6hoacsCK0ir/LKE3DcPgYlDI5OKegg= +auth-key +-----BEGIN ED25519 CERT----- +AQkAAAAAAewZqL9VZkkLDGVQ4eYcCdB/2+XvKqaT6DfOOdIK1zY8AQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PGJfiGR8BqiEDVPAUl0ToUn+72JW +zJUcnzAdNAysaR5UrK0i+/d4vOzNsNFQ2f7vkfFuwvQEeDlqw8lddvNwxg0= +-----END ED25519 CERT----- +enc-key ntor c6PGJQNAqJxyfC6O6VvvHc/wgMrQcKr3Ui7pBvv2dXE= +enc-key-cert +-----BEGIN ED25519 CERT----- +AQsAAAAAAR8cWg2fNApajIBOuKccs3OYfVopANyb9NDTpy7J86VTAQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PNm8uaG3bLDi06dUrHSEDBCIVsx5 +CPFWD3sGHVPIZA5JnK7mbX8zhsX/MnV/0jj+YKLCQtOPwbqC3R7iXWjBsAE= +-----END ED25519 CERT----- +introduction-point AQUEBgcICQ== +onion-key ntor tnEhX8317Kk2N6hoacsCK0ir/LKE3DcPgYlDI5OKegg= +auth-key +-----BEGIN ED25519 CERT----- +AQkAAAAAAewZqL9VZkkLDGVQ4eYcCdB/2+XvKqaT6DfOOdIK1zY8AQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PGJfiGR8BqiEDVPAUl0ToUn+72JW +zJUcnzAdNAysaR5UrK0i+/d4vOzNsNFQ2f7vkfFuwvQEeDlqw8lddvNwxg0= +-----END ED25519 CERT----- +enc-key ntor c6PGJQNAqJxyfC6O6VvvHc/wgMrQcKr3Ui7pBvv2dXE= +enc-key-cert +-----BEGIN ED25519 CERT----- +AQsAAAAAAR8cWg2fNApajIBOuKccs3OYfVopANyb9NDTpy7J86VTAQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PNm8uaG3bLDi06dUrHSEDBCIVsx5 +CPFWD3sGHVPIZA5JnK7mbX8zhsX/MnV/0jj+YKLCQtOPwbqC3R7iXWjBsAE= +-----END ED25519 CERT----- +introduction-point AQoECwwNDg== +onion-key ntor tnEhX8317Kk2N6hoacsCK0ir/LKE3DcPgYlDI5OKegg= +auth-key +-----BEGIN ED25519 CERT----- +AQkAAAAAAewZqL9VZkkLDGVQ4eYcCdB/2+XvKqaT6DfOOdIK1zY8AQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PGJfiGR8BqiEDVPAUl0ToUn+72JW +zJUcnzAdNAysaR5UrK0i+/d4vOzNsNFQ2f7vkfFuwvQEeDlqw8lddvNwxg0= +-----END ED25519 CERT----- +enc-key ntor c6PGJQNAqJxyfC6O6VvvHc/wgMrQcKr3Ui7pBvv2dXE= +enc-key-cert +-----BEGIN ED25519 CERT----- +AQsAAAAAAR8cWg2fNApajIBOuKccs3OYfVopANyb9NDTpy7J86VTAQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PNm8uaG3bLDi06dUrHSEDBCIVsx5 +CPFWD3sGHVPIZA5JnK7mbX8zhsX/MnV/0jj+YKLCQtOPwbqC3R7iXWjBsAE= +-----END ED25519 CERT----- +"# + ); + } + + #[test] + fn inner_hsdesc_too_many_link_specifiers() { + let hs_desc_sign = test_ed25519_keypair(); + + let link_spec = UnparsedLinkSpec::new(0, vec![]); + let link_specifiers = std::iter::repeat(link_spec) + .take(u8::MAX as usize + 1) + .collect::>(); + let intros = vec![test_intro_point_descriptor(link_specifiers)]; + + // A descriptor for a location-hidden service with an introduction point with too many link + // specifiers + let err = HsDescInnerBuilder::default() + .hs_desc_sign(&hs_desc_sign) + .create2_formats(&[1234]) + .auth_required(None) + .is_single_onion_service(false) + .intro_points(&intros) + .intro_auth_key_cert_expiry(UNIX_EPOCH) + .intro_enc_key_cert_expiry(UNIX_EPOCH) + .build_sign() + .unwrap_err(); + + assert!(expect_bug(err).contains("Too many link specifiers (max = 255).")); + } + + #[test] + fn inner_hsdesc_intro_auth() { + let hs_desc_sign = test_ed25519_keypair(); + let link_specs = vec![UnparsedLinkSpec::new(0, vec![1, 2, 3, 4])]; + let intros = vec![test_intro_point_descriptor(link_specs)]; + let auth = SmallVec::from([IntroAuthType::Ed25519, IntroAuthType::Ed25519]); + + // A descriptor for a location-hidden service with 1 introduction points which requires + // auth. + let hs_desc = HsDescInnerBuilder::default() + .hs_desc_sign(&hs_desc_sign) + .create2_formats(&[1234]) + .auth_required(Some(&auth)) + .is_single_onion_service(false) + .intro_points(&intros) + .intro_auth_key_cert_expiry(UNIX_EPOCH) + .intro_enc_key_cert_expiry(UNIX_EPOCH) + .build_sign() + .unwrap(); + + assert_eq!( + &*hs_desc, + r#"create2-formats 1234 +intro-auth-required ed25519 ed25519 +introduction-point AQAEAQIDBA== +onion-key ntor tnEhX8317Kk2N6hoacsCK0ir/LKE3DcPgYlDI5OKegg= +auth-key +-----BEGIN ED25519 CERT----- +AQkAAAAAAewZqL9VZkkLDGVQ4eYcCdB/2+XvKqaT6DfOOdIK1zY8AQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PGJfiGR8BqiEDVPAUl0ToUn+72JW +zJUcnzAdNAysaR5UrK0i+/d4vOzNsNFQ2f7vkfFuwvQEeDlqw8lddvNwxg0= +-----END ED25519 CERT----- +enc-key ntor c6PGJQNAqJxyfC6O6VvvHc/wgMrQcKr3Ui7pBvv2dXE= +enc-key-cert +-----BEGIN ED25519 CERT----- +AQsAAAAAAR8cWg2fNApajIBOuKccs3OYfVopANyb9NDTpy7J86VTAQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PNm8uaG3bLDi06dUrHSEDBCIVsx5 +CPFWD3sGHVPIZA5JnK7mbX8zhsX/MnV/0jj+YKLCQtOPwbqC3R7iXWjBsAE= +-----END ED25519 CERT----- +"# + ); + } +} diff --git a/crates/tor-netdoc/src/doc/hsdesc/build/middle.rs b/crates/tor-netdoc/src/doc/hsdesc/build/middle.rs new file mode 100644 index 000000000..ddde35b6c --- /dev/null +++ b/crates/tor-netdoc/src/doc/hsdesc/build/middle.rs @@ -0,0 +1,239 @@ +//! Functionality for encoding the middle document of an onion service descriptor. +//! +//! NOTE: `HsDescMiddleBuilder` is a private helper for building hidden service descriptors, and is +//! not meant to be used directly. Hidden services will use `HsDescBuilder` to build and encode +//! hidden service descriptors. + +use crate::build::NetdocEncoder; +use crate::doc::hsdesc::desc_enc::{HS_DESC_CLIENT_ID_LEN, HS_DESC_ENC_NONCE_LEN, HS_DESC_IV_LEN}; +use crate::doc::hsdesc::middle::{AuthClient, HsMiddleKwd, HS_DESC_AUTH_TYPE}; +use crate::{NetdocBuilder, NetdocText}; + +use tor_bytes::EncodeError; +use tor_error::into_bad_api_usage; +use tor_llcrypto::pk::curve25519::{EphemeralSecret, PublicKey}; +use tor_llcrypto::util::ct::CtByteArray; + +use base64ct::{Base64, Encoding}; +use rand::rngs::OsRng; +use rand::{thread_rng, Rng}; + +use std::borrow::Cow; + +mod private { + //! A private module that defines the `HsDescMiddle` structure. + //! + //! TODO: while you can use derive_builder to create a private "built" struct with a public + //! builder (by making the struct private and setting `build_fn(vis = "pub(super)")`), it does + //! not support finer grained visibility for the builder struct. This means we cannot make the + //! builder struct `pub(super)` while keeping the "built" struct private (which is what we want + //! here, as this builder only exists to serve as a helper for the top-level `HsDescBuilder`). + //! + //! This module was created as a workaround to this limitation. + + use crate::doc::hsdesc::build::ClientAuth; + + use derive_builder::Builder; + + /// The representation of the middle document of an onion service descriptor. + /// + /// The plaintext format of this document is described in section 2.5.1.2. of rend-spec-v3. + #[derive(Builder)] + #[builder(public, derive(Debug), pattern = "owned", build_fn(vis = "pub(super)"))] + pub(super) struct HsDescMiddle<'a> { + /// Client authorization parameters, if client authentication is enabled. If set to `None`, + /// client authentication is disabled. + pub(super) client_auth: Option<&'a ClientAuth>, + /// The (encrypted) inner document of the onion service descriptor. + /// + /// The `encrypted` field is generated by encrypting an inner document built using + /// [`crate::doc::hsdesc::build::inner::HsDescInnerBuilder`] as described in sections + /// 2.5.2.1. and 2.5.2.2. of rend-spec-v3. + pub(super) encrypted: Vec, + } +} + +pub(super) use private::HsDescMiddleBuilder; + +use private::HsDescMiddle; + +impl<'a> NetdocBuilder for HsDescMiddleBuilder<'a> { + fn build_sign(self) -> Result, EncodeError> { + use HsMiddleKwd::*; + + let HsDescMiddle { + client_auth, + encrypted, + } = self + .build() + .map_err(into_bad_api_usage!("the HsDescMiddle could not be built"))?; + + let mut encoder = NetdocEncoder::new(); + + let (ephemeral_key, auth_clients): (_, Cow>) = match client_auth { + Some(client_auth) if client_auth.auth_clients.is_empty() => { + return Err(tor_error::bad_api_usage!( + "client authentication is enabled, but there are no authorized clients" + ) + .into()); + } + Some(client_auth) => { + // Client auth is enabled. + ( + *client_auth.ephemeral_key, + Cow::Borrowed(&client_auth.auth_clients), + ) + } + None => { + // As per section 2.5.1.2. of rend-spec-v3, if client auth is disabled, we need to + // generate some fake data for the desc-auth-ephemeral-key and auth-client fields. + let secret = EphemeralSecret::new(OsRng); + let dummy_ephemeral_key = PublicKey::from(&secret); + + let mut rng = thread_rng(); + // Generate a single client-auth line filled with random values for client-id, + // iv, and encrypted-cookie. + let dummy_auth_client = AuthClient { + client_id: CtByteArray::from(rng.gen::<[u8; HS_DESC_CLIENT_ID_LEN]>()), + iv: rng.gen::<[u8; HS_DESC_IV_LEN]>(), + encrypted_cookie: rng.gen::<[u8; HS_DESC_ENC_NONCE_LEN]>(), + }; + + // TODO hs: Remove useless vec![] allocation. + (dummy_ephemeral_key, Cow::Owned(vec![dummy_auth_client])) + } + }; + + encoder.item(DESC_AUTH_TYPE).arg(&HS_DESC_AUTH_TYPE); + encoder + .item(DESC_AUTH_EPHEMERAL_KEY) + .arg(&Base64::encode_string(ephemeral_key.as_bytes())); + + for auth_client in &*auth_clients { + encoder + .item(AUTH_CLIENT) + .arg(&Base64::encode_string(&*auth_client.client_id)) + .arg(&Base64::encode_string(&auth_client.iv)) + .arg(&Base64::encode_string(&auth_client.encrypted_cookie)); + } + + encoder.item(ENCRYPTED).object("MESSAGE", encrypted); + encoder.finish().map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_duration_subtraction)] + //! + + use super::*; + use crate::doc::hsdesc::build::test::{ + expect_bug, TEST_CURVE25519_PUBLIC1, TEST_DESCRIPTOR_COOKIE, + }; + use crate::doc::hsdesc::build::ClientAuth; + use tor_llcrypto::pk::curve25519; + + // Some dummy bytes, not actually encrypted. + const TEST_ENCRYPTED_VALUE: &[u8] = &[1, 2, 3, 4]; + + #[test] + fn middle_hsdesc_encoding_no_client_auth() { + let hs_desc = HsDescMiddleBuilder::default() + .client_auth(None) + .encrypted(TEST_ENCRYPTED_VALUE.into()) + .build_sign() + .unwrap(); + + let mut lines = hs_desc.splitn(4, '\n'); + + assert_eq!(lines.next().unwrap(), "desc-auth-type x25519"); + // If client auth is disabled, HsDescMiddleBuilder will generate a dummy ephemeral key (a + // different one each time), so this test checks it is present in the built document. It + // does _not_ actually validate its value. + assert!(lines + .next() + .unwrap() + .starts_with("desc-auth-ephemeral-key ")); + // The above also applies to the auth-client line (since client auth is disabled, it's + // going to have a different value on each run). + assert!(lines.next().unwrap().starts_with("auth-client ")); + assert_eq!( + lines.next().unwrap(), + r#"encrypted +-----BEGIN MESSAGE----- +AQIDBA== +-----END MESSAGE----- +"# + ); + assert!(lines.next().is_none()); + } + + #[test] + fn middle_hsdesc_encoding_with_bad_client_auth() { + let client_auth = ClientAuth { + ephemeral_key: curve25519::PublicKey::from(TEST_CURVE25519_PUBLIC1).into(), + auth_clients: vec![], + descriptor_cookie: TEST_DESCRIPTOR_COOKIE, + }; + + let err = HsDescMiddleBuilder::default() + .client_auth(Some(&client_auth)) + .encrypted(TEST_ENCRYPTED_VALUE.into()) + .build_sign() + .unwrap_err(); + + assert!(expect_bug(err) + .contains("client authentication is enabled, but there are no authorized clients")); + } + + #[test] + fn middle_hsdesc_encoding_client_auth() { + // 2 authorized clients + let auth_clients = vec![ + AuthClient { + client_id: [2; 8].into(), + iv: [2; 16], + encrypted_cookie: [3; 16], + }, + AuthClient { + client_id: [4; 8].into(), + iv: [5; 16], + encrypted_cookie: [6; 16], + }, + ]; + + let client_auth = ClientAuth { + ephemeral_key: curve25519::PublicKey::from(TEST_CURVE25519_PUBLIC1).into(), + auth_clients, + descriptor_cookie: TEST_DESCRIPTOR_COOKIE, + }; + + let hs_desc = HsDescMiddleBuilder::default() + .client_auth(Some(&client_auth)) + .encrypted(TEST_ENCRYPTED_VALUE.into()) + .build_sign() + .unwrap(); + + assert_eq!( + &*hs_desc, + r#"desc-auth-type x25519 +desc-auth-ephemeral-key tnEhX8317Kk2N6hoacsCK0ir/LKE3DcPgYlDI5OKegg= +auth-client AgICAgICAgI= AgICAgICAgICAgICAgICAg== AwMDAwMDAwMDAwMDAwMDAw== +auth-client BAQEBAQEBAQ= BQUFBQUFBQUFBQUFBQUFBQ== BgYGBgYGBgYGBgYGBgYGBg== +encrypted +-----BEGIN MESSAGE----- +AQIDBA== +-----END MESSAGE----- +"# + ); + } +} diff --git a/crates/tor-netdoc/src/doc/hsdesc/build/outer.rs b/crates/tor-netdoc/src/doc/hsdesc/build/outer.rs new file mode 100644 index 000000000..cfec2e1b8 --- /dev/null +++ b/crates/tor-netdoc/src/doc/hsdesc/build/outer.rs @@ -0,0 +1,183 @@ +//! Functionality for encoding the outer document of an onion service descriptor. +//! +//! NOTE: `HsDescOuterBuilder` is a private helper for building hidden service descriptors, and is +//! not meant to be used directly. Hidden services will use `HsDescBuilder` to build and encode +//! hidden service descriptors. + +use crate::build::NetdocEncoder; +use crate::build::{NetdocBuilder, NetdocText}; +use crate::doc::hsdesc::outer::{HsOuterKwd, HS_DESC_SIGNATURE_PREFIX, HS_DESC_VERSION_CURRENT}; + +use tor_bytes::EncodeError; +use tor_cert::{CertType, CertifiedKey, Ed25519Cert}; +use tor_error::into_bad_api_usage; +use tor_llcrypto::pk::ed25519; + +use base64ct::{Base64, Encoding}; + +mod private { + //! A private module that defines the `HsDescOuter` structure. + //! + //! TODO: while you can use derive_builder to create a private "built" struct with a public + //! builder (by making the struct private and setting `build_fn(vis = "pub(super)")`), it does + //! not support finer grained visibility for the builder struct. This means we cannot make the + //! builder struct `pub(super)` while keeping the "built" struct private (which is what we want + //! here, as this builder only exists to serve as a helper for the top-level `HsDescBuilder`). + //! + //! This module was created as a workaround to this limitation. + + use std::time::SystemTime; + + use derive_builder::Builder; + use tor_hscrypto::RevisionCounter; + use tor_llcrypto::pk::ed25519; + use tor_units::IntegerMinutes; + + /// The representation of the outer wrapper of an onion service descriptor. + /// + /// The format of this document is described in section 2.4. of rend-spec-v3. + #[derive(Builder)] + #[builder(public, derive(Debug), pattern = "owned", build_fn(vis = "pub(super)"))] + pub(super) struct HsDescOuter<'a> { + /// The blinded hidden service signing keys used to sign descriptor signing keys + /// (KP_hs_blind_id, KS_hs_blind_id). + pub(super) blinded_id: &'a ed25519::Keypair, + /// The short-term descriptor signing key. + pub(super) hs_desc_sign: &'a ed25519::Keypair, + /// The expiration time of the descriptor signing key certificate. + pub(super) hs_desc_sign_cert_expiry: SystemTime, + /// The lifetime of this descriptor, in minutes. + /// + /// This doesn't actually list the starting time or the end time for the + /// descriptor: presumably, because we didn't want to leak the onion + /// service's view of the wallclock. + pub(super) lifetime: IntegerMinutes, + /// A revision counter to tell whether this descriptor is more or less recent + /// than another one for the same blinded ID. + pub(super) revision_counter: RevisionCounter, + /// The (superencrypted) middle document of the onion service descriptor. + /// + /// The superencrypted field is generated by encrypting a middle document built using + /// [`crate::doc::hsdesc::build::middle::HsDescMiddleBuilder`] as described in sections + /// 2.5.1.1. and 2.5.1.2. of rend-spec-v3. + pub(super) superencrypted: Vec, + } +} + +pub(super) use private::HsDescOuterBuilder; + +use private::HsDescOuter; + +impl<'a> NetdocBuilder for HsDescOuterBuilder<'a> { + fn build_sign(self) -> Result, EncodeError> { + use HsOuterKwd::*; + + let HsDescOuter { + blinded_id, + hs_desc_sign, + hs_desc_sign_cert_expiry, + lifetime, + revision_counter, + superencrypted, + } = self + .build() + .map_err(into_bad_api_usage!("failed to build HsDescOuter"))?; + + let mut encoder = NetdocEncoder::new(); + let beginning = encoder.cursor(); + encoder.item(HS_DESCRIPTOR).arg(&HS_DESC_VERSION_CURRENT); + encoder.item(DESCRIPTOR_LIFETIME).arg(&lifetime.to_string()); + + // "The certificate cross-certifies the short-term descriptor signing key with the blinded + // public key. The certificate type must be [08], and the blinded public key must be + // present as the signing-key extension." + let desc_signing_key_cert = Ed25519Cert::constructor() + .cert_type(CertType::HS_BLINDED_ID_V_SIGNING) + .expiration(hs_desc_sign_cert_expiry) + .signing_key(ed25519::Ed25519Identity::from(&blinded_id.public)) + .cert_key(CertifiedKey::Ed25519(hs_desc_sign.public.into())) + .encode_and_sign(blinded_id) + .map_err(into_bad_api_usage!( + "failed to sign the descriptor signing key" + ))?; + + encoder + .item(DESCRIPTOR_SIGNING_KEY_CERT) + .object("ED25519 CERT", desc_signing_key_cert); + encoder.item(REVISION_COUNTER).arg(&*revision_counter); + + // TODO: According to section 2.4. of rend-spec-v3 this blob should _not_ end with a + // newline character. We need to update the encoder to support this. + encoder + .item(SUPERENCRYPTED) + .object("MESSAGE", superencrypted); + let end = encoder.cursor(); + + let mut text = HS_DESC_SIGNATURE_PREFIX.to_vec(); + text.extend_from_slice(encoder.slice(beginning, end)?.as_bytes()); + let signature = ed25519::ExpandedSecretKey::from(&hs_desc_sign.secret) + .sign(&text, &hs_desc_sign.public); + encoder + .item(SIGNATURE) + .arg(&Base64::encode_string(&signature.to_bytes())); + + encoder.finish().map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + // @@ begin test lint list maintained by maint/add_warning @@ + #![allow(clippy::bool_assert_comparison)] + #![allow(clippy::clone_on_copy)] + #![allow(clippy::dbg_macro)] + #![allow(clippy::print_stderr)] + #![allow(clippy::print_stdout)] + #![allow(clippy::single_char_pattern)] + #![allow(clippy::unwrap_used)] + #![allow(clippy::unchecked_duration_subtraction)] + //! + + use std::time::UNIX_EPOCH; + + use super::*; + use crate::doc::hsdesc::build::test::test_ed25519_keypair; + use tor_units::IntegerMinutes; + + // Some dummy bytes, not actually encrypted. + const TEST_SUPERENCRYPTED_VALUE: &[u8] = &[1, 2, 3, 4]; + + #[test] + fn outer_hsdesc() { + let blinded_id = test_ed25519_keypair(); + let hs_desc_sign = test_ed25519_keypair(); + let hs_desc = HsDescOuterBuilder::default() + .blinded_id(&blinded_id) + .hs_desc_sign(&hs_desc_sign) + .hs_desc_sign_cert_expiry(UNIX_EPOCH) + .lifetime(IntegerMinutes::new(20)) + .revision_counter(9001.into()) + .superencrypted(TEST_SUPERENCRYPTED_VALUE.into()) + .build_sign() + .unwrap(); + + assert_eq!( + &*hs_desc, + r#"hs-descriptor 3 +descriptor-lifetime 20 +descriptor-signing-key-cert +-----BEGIN ED25519 CERT----- +AQgAAAAAAewZqL9VZkkLDGVQ4eYcCdB/2+XvKqaT6DfOOdIK1zY8AQAgBADsGai/ +VWZJCwxlUOHmHAnQf9vl7yqmk+g3zjnSCtc2PFnSUwwD3tSQYaF4DKBVemqZwhm+ +6jWFAvdK+7LsP/HaE0r6JcT7pOchkyczbBns2gCdGULRvejzNoq3gegDIQ0= +-----END ED25519 CERT----- +revision-counter 9001 +superencrypted +-----BEGIN MESSAGE----- +AQIDBA== +-----END MESSAGE----- +signature +Y3v0te3Cq4HdfliGTpl3kIKwcMBaRWAclxXRxOcVb1hI+3UNvT59JfJjz8JrBEc1N8TjxlxtTCvJKU4Xy9jBQ== +"# + ); + } +} diff --git a/crates/tor-netdoc/src/doc/hsdesc/desc_enc.rs b/crates/tor-netdoc/src/doc/hsdesc/desc_enc.rs index 89452c6ed..4f74c34a3 100644 --- a/crates/tor-netdoc/src/doc/hsdesc/desc_enc.rs +++ b/crates/tor-netdoc/src/doc/hsdesc/desc_enc.rs @@ -42,13 +42,13 @@ pub(super) struct HsDescEncryption<'a> { } /// The length of a client ID. -pub(crate) const HS_DESC_MIDDLE_CLIENT_ID_LEN: usize = 8; +pub(crate) const HS_DESC_CLIENT_ID_LEN: usize = 8; /// The length of the the `AuthClient` IV. -pub(crate) const HS_DESC_MIDDLE_IV_LEN: usize = 16; +pub(crate) const HS_DESC_IV_LEN: usize = 16; /// The length of an `N_hs_desc_enc` nonce (also known as a "descriptor cookie"). -pub(crate) const HS_DESC_MIDDLE_ENC_NONCE_LEN: usize = 16; +pub(crate) const HS_DESC_ENC_NONCE_LEN: usize = 16; /// A value used in deriving the encryption key for the inner (encryption) layer /// of onion service encryption. @@ -56,7 +56,7 @@ pub(crate) const HS_DESC_MIDDLE_ENC_NONCE_LEN: usize = 16; /// This is `N_hs_desc_enc` in the spec, where sometimes we also call it a /// "descriptor cookie". #[derive(derive_more::AsRef, derive_more::From)] -pub(super) struct HsDescEncNonce([u8; HS_DESC_MIDDLE_ENC_NONCE_LEN]); +pub(super) struct HsDescEncNonce([u8; HS_DESC_ENC_NONCE_LEN]); /// Length of our cryptographic salt. const SALT_LEN: usize = 16; diff --git a/crates/tor-netdoc/src/doc/hsdesc/inner.rs b/crates/tor-netdoc/src/doc/hsdesc/inner.rs index 2b98e6234..e7ec86d3d 100644 --- a/crates/tor-netdoc/src/doc/hsdesc/inner.rs +++ b/crates/tor-netdoc/src/doc/hsdesc/inner.rs @@ -39,7 +39,7 @@ pub(super) struct HsDescInner { } decl_keyword! { - HsInnerKwd { + pub(crate) HsInnerKwd { "create2-formats" => CREATE2_FORMATS, "intro-auth-required" => INTRO_AUTH_REQUIRED, "single-onion-service" => SINGLE_ONION_SERVICE, diff --git a/crates/tor-netdoc/src/doc/hsdesc/middle.rs b/crates/tor-netdoc/src/doc/hsdesc/middle.rs index b3df1ca2c..6dd08e872 100644 --- a/crates/tor-netdoc/src/doc/hsdesc/middle.rs +++ b/crates/tor-netdoc/src/doc/hsdesc/middle.rs @@ -13,11 +13,14 @@ use crate::types::misc::B64; use crate::{Pos, Result}; use super::desc_enc::{ - HsDescEncNonce, HsDescEncryption, HS_DESC_MIDDLE_CLIENT_ID_LEN, HS_DESC_MIDDLE_ENC_NONCE_LEN, - HS_DESC_MIDDLE_IV_LEN, + HsDescEncNonce, HsDescEncryption, HS_DESC_CLIENT_ID_LEN, HS_DESC_ENC_NONCE_LEN, HS_DESC_IV_LEN, }; use super::DecryptionError; +/// TODO hs: This should be an enum. +/// The only currently recognized `desc-auth-type`. +pub(super) const HS_DESC_AUTH_TYPE: &str = "x25519"; + /// A more-or-less verbatim representation of the middle document of an onion /// service descriptor. #[derive(Debug, Clone)] @@ -134,15 +137,15 @@ impl HsDescMiddle { /// Information that a single authorized client can use to decrypt the onion /// service descriptor. #[derive(Debug, Clone)] -struct AuthClient { +pub struct AuthClient { /// A check field that clients can use to see if this [`AuthClient`] entry corresponds to a key they hold. /// /// This is the first part of the `auth-client` line. - client_id: CtByteArray, + pub(crate) client_id: CtByteArray, /// An IV used to decrypt `encrypted_cookie`. /// /// This is the second item on the `auth-client` line. - iv: [u8; HS_DESC_MIDDLE_IV_LEN], + pub(crate) iv: [u8; HS_DESC_IV_LEN], /// An encrypted value used to find the descriptor cookie `N_hs_desc_enc`, /// which in turn is /// needed to decrypt the [HsDescMiddle]'s `encrypted_body`. @@ -150,7 +153,7 @@ struct AuthClient { /// This is the third item on the `auth-client` line. When decrypted, it /// reveals a `DescEncEncryptionCookie` (`N_hs_desc_enc`, not yet so named /// in the spec). - encrypted_cookie: [u8; HS_DESC_MIDDLE_ENC_NONCE_LEN], + pub(crate) encrypted_cookie: [u8; HS_DESC_ENC_NONCE_LEN], } impl AuthClient { @@ -173,7 +176,7 @@ impl AuthClient { } decl_keyword! { - HsMiddleKwd { + pub(crate) HsMiddleKwd { "desc-auth-type" => DESC_AUTH_TYPE, "desc-auth-ephemeral-key" => DESC_AUTH_EPHEMERAL_KEY, "auth-client" => AUTH_CLIENT, diff --git a/crates/tor-netdoc/src/doc/hsdesc/outer.rs b/crates/tor-netdoc/src/doc/hsdesc/outer.rs index 5b44be896..bc2adc43a 100644 --- a/crates/tor-netdoc/src/doc/hsdesc/outer.rs +++ b/crates/tor-netdoc/src/doc/hsdesc/outer.rs @@ -16,6 +16,12 @@ use crate::{Pos, Result}; use super::desc_enc; +/// The current version-number. +pub(super) const HS_DESC_VERSION_CURRENT: &str = "3"; + +/// The text the outer document signature is prefixed with. +pub(super) const HS_DESC_SIGNATURE_PREFIX: &[u8] = b"Tor onion service descriptor sig v3"; + /// A more-or-less verbatim representation of the outermost plaintext document /// of an onion service descriptor. #[derive(Clone, Debug)] @@ -99,7 +105,7 @@ impl HsDescOuter { pub(super) type UncheckedHsDescOuter = SignatureGated>; decl_keyword! { - HsOuterKwd { + pub(crate) HsOuterKwd { "hs-descriptor" => HS_DESCRIPTOR, "descriptor-lifetime" => DESCRIPTOR_LIFETIME, "descriptor-signing-key-cert" => DESCRIPTOR_SIGNING_KEY_CERT, @@ -136,7 +142,7 @@ impl HsDescOuter { Ok(result) } - /// Extract an HsDescOuter from a reader. + /// Extract an HsDescOuter from a reader. /// /// The reader must contain a single HsDescOuter; we return an error if not. fn take_from_reader(reader: &mut NetDocReader<'_, HsOuterKwd>) -> Result { @@ -176,7 +182,7 @@ impl HsDescOuter { // TODO: This way of handling prefixes does a needless // allocation. Someday we could make our signature-checking // logic even smarter. - let mut signed_text = b"Tor onion service descriptor sig v3".to_vec(); + let mut signed_text = HS_DESC_SIGNATURE_PREFIX.to_vec(); signed_text.extend_from_slice( s.get(start_idx..end_idx) .expect("Somehow the first item came after the last‽") @@ -188,7 +194,7 @@ impl HsDescOuter { // Check that the hs-descriptor version is 3. { let version = body.required(HS_DESCRIPTOR)?.required_arg(0)?; - if version != "3" { + if version != HS_DESC_VERSION_CURRENT { return Err(EK::BadDocumentVersion .with_msg(format!("Unexpected hsdesc version {}", version)) .at_pos(Pos::at(version))); diff --git a/crates/tor-netdoc/src/lib.rs b/crates/tor-netdoc/src/lib.rs index 474379a4e..f996141bd 100644 --- a/crates/tor-netdoc/src/lib.rs +++ b/crates/tor-netdoc/src/lib.rs @@ -56,7 +56,7 @@ pub use err::{BuildError, Error, ParseErrorKind, Pos}; #[cfg(feature = "hs-service")] #[cfg_attr(docsrs, doc(cfg(feature = "hs-service")))] -pub use build::NetdocText; +pub use build::{NetdocBuilder, NetdocText}; /// Alias for the Result type returned by most objects in this module. pub type Result = std::result::Result;