tor_netdoc/doc/hsdesc/
inner.rs

1//! Code to handle the inner document of an onion service descriptor.
2
3use std::time::SystemTime;
4
5use super::{IntroAuthType, IntroPointDesc};
6use crate::batching_split_before::IteratorExt as _;
7use crate::doc::hsdesc::pow::PowParamSet;
8use crate::parse::tokenize::{ItemResult, NetDocReader};
9use crate::parse::{keyword::Keyword, parser::SectionRules};
10use crate::types::misc::{B64, UnvalidatedEdCert};
11use crate::{NetdocErrorKind as EK, Result};
12
13use itertools::Itertools as _;
14use smallvec::SmallVec;
15use std::sync::LazyLock;
16use tor_checkable::Timebound;
17use tor_checkable::signed::SignatureGated;
18use tor_checkable::timed::TimerangeBound;
19use tor_hscrypto::NUM_INTRO_POINT_MAX;
20use tor_hscrypto::pk::{HsIntroPtSessionIdKey, HsSvcNtorKey};
21use tor_llcrypto::pk::ed25519::Ed25519Identity;
22use tor_llcrypto::pk::{ValidatableSignature, curve25519, ed25519};
23
24/// The contents of the inner document of an onion service descriptor.
25#[derive(Debug, Clone)]
26#[cfg_attr(feature = "hsdesc-inner-docs", visibility::make(pub))]
27pub(crate) struct HsDescInner {
28    /// The authentication types that this onion service accepts when
29    /// connecting.
30    //
31    // TODO: This should probably be a bitfield or enum-set of something.
32    // Once we know whether the "password" authentication type really exists,
33    // let's change to a better representation here.
34    pub(super) intro_auth_types: Option<SmallVec<[IntroAuthType; 2]>>,
35    /// Is this onion service a "single onion service?"
36    ///
37    /// (A "single onion service" is one that is not attempting to anonymize
38    /// itself.)
39    pub(super) single_onion_service: bool,
40    /// A list of advertised introduction points and their contact info.
41    //
42    // Always has >= 1 and <= NUM_INTRO_POINT_MAX entries
43    pub(super) intro_points: Vec<IntroPointDesc>,
44    /// A list of offered proof-of-work parameters, at most one per type.
45    pub(super) pow_params: PowParamSet,
46}
47
48decl_keyword! {
49    pub(crate) HsInnerKwd {
50        "create2-formats" => CREATE2_FORMATS,
51        "intro-auth-required" => INTRO_AUTH_REQUIRED,
52        "single-onion-service" => SINGLE_ONION_SERVICE,
53        "introduction-point" => INTRODUCTION_POINT,
54        "onion-key" => ONION_KEY,
55        "auth-key" => AUTH_KEY,
56        "enc-key" => ENC_KEY,
57        "enc-key-cert" => ENC_KEY_CERT,
58        "legacy-key" => LEGACY_KEY,
59        "legacy-key-cert" => LEGACY_KEY_CERT,
60        "pow-params" => POW_PARAMS,
61    }
62}
63
64/// Rules about how keywords appear in the header part of an onion service
65/// descriptor.
66static HS_INNER_HEADER_RULES: LazyLock<SectionRules<HsInnerKwd>> = LazyLock::new(|| {
67    use HsInnerKwd::*;
68
69    let mut rules = SectionRules::builder();
70    rules.add(CREATE2_FORMATS.rule().required().args(1..));
71    rules.add(INTRO_AUTH_REQUIRED.rule().args(1..));
72    rules.add(SINGLE_ONION_SERVICE.rule());
73    rules.add(POW_PARAMS.rule().args(1..).may_repeat().obj_optional());
74    rules.add(UNRECOGNIZED.rule().may_repeat().obj_optional());
75
76    rules.build()
77});
78
79/// Rules about how keywords appear in each introduction-point section of an
80/// onion service descriptor.
81static HS_INNER_INTRO_RULES: LazyLock<SectionRules<HsInnerKwd>> = LazyLock::new(|| {
82    use HsInnerKwd::*;
83
84    let mut rules = SectionRules::builder();
85    rules.add(INTRODUCTION_POINT.rule().required().args(1..));
86    // Note: we're labeling ONION_KEY and ENC_KEY as "may_repeat", since even
87    // though rend-spec labels them as "exactly once", they are allowed to
88    // appear more than once so long as they appear only once _with an "ntor"_
89    // key.  torspec!110 tries to document this issue.
90    rules.add(ONION_KEY.rule().required().may_repeat().args(2..));
91    rules.add(AUTH_KEY.rule().required().obj_required());
92    rules.add(ENC_KEY.rule().required().may_repeat().args(2..));
93    rules.add(ENC_KEY_CERT.rule().required().obj_required());
94    rules.add(UNRECOGNIZED.rule().may_repeat().obj_optional());
95
96    // NOTE: We never look at the LEGACY_KEY* fields.  This does provide a
97    // distinguisher for Arti implementations and C tor implementations, but
98    // that's outside of Arti's threat model.
99    //
100    // (In fact, there's an easier distinguisher, since we enforce UTF-8 in
101    // these documents, and C tor does not.)
102
103    rules.build()
104});
105
106/// Helper type returned when we parse an HsDescInner.
107pub(crate) type UncheckedHsDescInner = TimerangeBound<SignatureGated<HsDescInner>>;
108
109/// Information about one of the certificates inside an HsDescInner.
110///
111/// This is a teporary structure that we use when parsing.
112struct InnerCertData {
113    /// The identity of the key that purportedly signs this certificate.
114    signing_key: Ed25519Identity,
115    /// The key that is being signed.
116    subject_key: ed25519::PublicKey,
117    /// A detached signature object that we must validate before we can conclude
118    /// that the certificate is valid.
119    signature: Box<dyn ValidatableSignature>,
120    /// The time when the certificate expires.
121    expiry: SystemTime,
122}
123
124/// Decode a certificate from `tok`, and check that its tag and type are
125/// expected, that it contains a signing key,  and that both signing and subject
126/// keys are Ed25519.
127///
128/// On success, return an InnerCertData.
129fn handle_inner_certificate(
130    tok: &crate::parse::tokenize::Item<HsInnerKwd>,
131    want_tag: &str,
132    want_type: tor_cert::CertType,
133) -> Result<InnerCertData> {
134    let make_err = |e, msg| {
135        EK::BadObjectVal
136            .with_msg(msg)
137            .with_source(e)
138            .at_pos(tok.pos())
139    };
140
141    let cert = tok
142        .parse_obj::<UnvalidatedEdCert>(want_tag)?
143        .check_cert_type(want_type)?
144        .into_unchecked();
145
146    // These certs have to include a signing key.
147    let cert = cert
148        .should_have_signing_key()
149        .map_err(|e| make_err(e, "Certificate was not self-signed"))?;
150
151    // Peel off the signature.
152    let (cert, signature) = cert
153        .dangerously_split()
154        .map_err(|e| make_err(e, "Certificate was not Ed25519-signed"))?;
155    let signature = Box::new(signature);
156
157    // Peel off the expiration
158    let cert = cert.dangerously_assume_timely();
159    let expiry = cert.expiry();
160    let subject_key = cert
161        .subject_key()
162        .as_ed25519()
163        .ok_or_else(|| {
164            EK::BadObjectVal
165                .with_msg("Certified key was not Ed25519")
166                .at_pos(tok.pos())
167        })?
168        .try_into()
169        .map_err(|_| {
170            EK::BadObjectVal
171                .with_msg("Certified key was not valid Ed25519")
172                .at_pos(tok.pos())
173        })?;
174
175    let signing_key = *cert.signing_key().ok_or_else(|| {
176        EK::BadObjectVal
177            .with_msg("Signing key was not Ed25519")
178            .at_pos(tok.pos())
179    })?;
180
181    Ok(InnerCertData {
182        signing_key,
183        subject_key,
184        signature,
185        expiry,
186    })
187}
188
189impl HsDescInner {
190    /// Attempt to parse the inner document of an onion service descriptor from a
191    /// provided string.
192    ///
193    /// On success, return the signing key that was used for every certificate in the
194    /// inner document, and the inner document itself.
195    #[cfg_attr(feature = "hsdesc-inner-docs", visibility::make(pub))]
196    pub(super) fn parse(s: &str) -> Result<(Option<Ed25519Identity>, UncheckedHsDescInner)> {
197        let mut reader = NetDocReader::new(s)?;
198        let result = Self::take_from_reader(&mut reader).map_err(|e| e.within(s))?;
199        Ok(result)
200    }
201
202    /// Attempt to parse the inner document of an onion service descriptor from a
203    /// provided reader.
204    ///
205    /// On success, return the signing key that was used for every certificate in the
206    /// inner document, and the inner document itself.
207    //
208    // TODO: replace Itertools::exactly_one() with a stdlib equivalent when there is one.
209    //
210    // See issue #48919 <https://github.com/rust-lang/rust/issues/48919>
211    #[allow(unstable_name_collisions)]
212    fn take_from_reader(
213        input: &mut NetDocReader<'_, HsInnerKwd>,
214    ) -> Result<(Option<Ed25519Identity>, UncheckedHsDescInner)> {
215        use HsInnerKwd::*;
216
217        // Split up the input at INTRODUCTION_POINT items
218        let mut sections =
219            input.batching_split_before_with_header(|item| item.is_ok_with_kwd(INTRODUCTION_POINT));
220        // Parse the header.
221        let header = HS_INNER_HEADER_RULES.parse(&mut sections)?;
222
223        // Make sure that the "ntor" handshake is supported in the list of
224        // `HTYPE`s (handshake types) in `create2-formats`.
225        {
226            let tok = header.required(CREATE2_FORMATS)?;
227            // If we ever want to support a different HTYPE, we'll need to
228            // store at least the intersection between "their" and "our" supported
229            // HTYPEs.  For now we only support one, so either this set is empty
230            // and failing now is fine, or `ntor` (2) is supported, so fine.
231            if !tok.args().any(|s| s == "2") {
232                return Err(EK::BadArgument
233                    .at_pos(tok.pos())
234                    .with_msg("Onion service descriptor does not support ntor handshake."));
235            }
236        }
237        // Check whether any kind of introduction-point authentication is
238        // specified in an `intro-auth-required` line.
239        let auth_types = if let Some(tok) = header.get(INTRO_AUTH_REQUIRED) {
240            let mut auth_types: SmallVec<[IntroAuthType; 2]> = SmallVec::new();
241            let mut push = |at| {
242                if !auth_types.contains(&at) {
243                    auth_types.push(at);
244                }
245            };
246            for arg in tok.args() {
247                #[allow(clippy::single_match)]
248                match arg {
249                    "ed25519" => push(IntroAuthType::Ed25519),
250                    _ => (), // Ignore unrecognized types.
251                }
252            }
253            // .. but if no types are recognized, we can't connect.
254            if auth_types.is_empty() {
255                return Err(EK::BadArgument
256                    .at_pos(tok.pos())
257                    .with_msg("No recognized introduction authentication methods."));
258            }
259
260            Some(auth_types)
261        } else {
262            None
263        };
264
265        // Recognize `single-onion-service` if it's there.
266        let is_single_onion_service = header.get(SINGLE_ONION_SERVICE).is_some();
267
268        // Recognize `pow-params`, parsing each line and rejecting duplicate types
269        let pow_params = PowParamSet::from_items(header.slice(POW_PARAMS))?;
270
271        let mut signatures = Vec::new();
272        let mut expirations = Vec::new();
273        let mut cert_signing_key: Option<Ed25519Identity> = None;
274
275        // Now we parse the introduction points.  Each of these will be a
276        // section starting with `introduction-point`, ending right before the
277        // next `introduction-point` (or before the end of the document.)
278        let mut intro_points = Vec::new();
279        let mut sections = sections.subsequent();
280        while let Some(mut ipt_section) = sections.next_batch() {
281            let ipt_section = HS_INNER_INTRO_RULES.parse(&mut ipt_section)?;
282
283            // Parse link-specifiers
284            let link_specifiers = {
285                let tok = ipt_section.required(INTRODUCTION_POINT)?;
286                let ls = tok.parse_arg::<B64>(0)?;
287                let mut r = tor_bytes::Reader::from_slice(ls.as_bytes());
288                let n = r.take_u8()?;
289                let res = r.extract_n(n.into())?;
290                r.should_be_exhausted()?;
291                res
292            };
293
294            // Parse the ntor "onion-key" (`KP_ntor`) of the introduction point.
295            let ntor_onion_key = {
296                let tok = ipt_section
297                    .slice(ONION_KEY)
298                    .iter()
299                    .filter(|item| item.arg(0) == Some("ntor"))
300                    .exactly_one()
301                    .map_err(|_| EK::MissingToken.with_msg("No unique ntor onion key found."))?;
302                tok.parse_arg::<B64>(1)?.into_array()?.into()
303            };
304
305            // Extract the auth_key (`KP_hs_ipt_sid`) from the (unchecked)
306            // "auth-key" certificate.
307            let auth_key: HsIntroPtSessionIdKey = {
308                // Note that this certificate does not actually serve any
309                // function _as_ a certificate; it was meant to cross-certify
310                // the descriptor signing key (`KP_hs_desc_sign`) using the
311                // authentication key (`KP_hs_ipt_sid`).  But the C tor
312                // implementation got it backwards.
313                //
314                // We have to parse this certificate to extract
315                // `KP_hs_ipt_sid`, but we don't actually need to validate it:
316                // it appears inside the inner document, which is already signed
317                // with `KP_hs_desc_sign`.  Nonetheless, we validate it anyway,
318                // since that's what C tor does.
319                //
320                // See documentation for `CertType::HS_IP_V_SIGNING for more
321                // info`.
322                let tok = ipt_section.required(AUTH_KEY)?;
323                let InnerCertData {
324                    signing_key,
325                    subject_key,
326                    signature,
327                    expiry,
328                } = handle_inner_certificate(
329                    tok,
330                    "ED25519 CERT",
331                    tor_cert::CertType::HS_IP_V_SIGNING,
332                )?;
333                expirations.push(expiry);
334                signatures.push(signature);
335                if cert_signing_key.get_or_insert(signing_key) != &signing_key {
336                    return Err(EK::BadObjectVal
337                        .at_pos(tok.pos())
338                        .with_msg("Mismatched signing key"));
339                }
340
341                subject_key.into()
342            };
343
344            // Extract the key `KP_hss_ntor` that we'll use for our
345            // handshake with the onion service itself.  This comes from the
346            // "enc-key" item.
347            let svc_ntor_key: HsSvcNtorKey = {
348                let tok = ipt_section
349                    .slice(ENC_KEY)
350                    .iter()
351                    .filter(|item| item.arg(0) == Some("ntor"))
352                    .exactly_one()
353                    .map_err(|_| EK::MissingToken.with_msg("No unique ntor onion key found."))?;
354                let key = curve25519::PublicKey::from(tok.parse_arg::<B64>(1)?.into_array()?);
355                key.into()
356            };
357
358            // Check that the key in the "enc-key-cert" item matches the
359            // `KP_hss_ntor` we just extracted.
360            {
361                // NOTE: As above, this certificate is backwards, and hence
362                // useless.  Still, we validate it because that is what C tor does.
363                let tok = ipt_section.required(ENC_KEY_CERT)?;
364                let InnerCertData {
365                    signing_key,
366                    subject_key,
367                    signature,
368                    expiry,
369                } = handle_inner_certificate(
370                    tok,
371                    "ED25519 CERT",
372                    tor_cert::CertType::HS_IP_CC_SIGNING,
373                )?;
374                expirations.push(expiry);
375                signatures.push(signature);
376
377                // Yes, the sign bit is always zero here. This would have a 50%
378                // chance of making  the key unusable for verification. But since
379                // the certificate is backwards (see above) we don't actually have
380                // to check any signatures with it.
381                let sign_bit = 0;
382                let expected_ed_key =
383                    tor_llcrypto::pk::keymanip::convert_curve25519_to_ed25519_public(
384                        &svc_ntor_key,
385                        sign_bit,
386                    );
387                if expected_ed_key != Some(subject_key) {
388                    return Err(EK::BadObjectVal
389                        .at_pos(tok.pos())
390                        .with_msg("Mismatched subject key"));
391                }
392
393                // Make sure signing key is as expected.
394                if cert_signing_key.get_or_insert(signing_key) != &signing_key {
395                    return Err(EK::BadObjectVal
396                        .at_pos(tok.pos())
397                        .with_msg("Mismatched signing key"));
398                }
399            };
400
401            // TODO SPEC: State who enforces NUM_INTRO_POINT_MAX and how (hsdirs, clients?)
402            //
403            // Simply discard extraneous IPTs.  The MAX value is hardcoded now, but a future
404            // protocol evolution might increase it and we should probably still work then.
405            //
406            // If the spec intended that hsdirs ought to validate this and reject descriptors
407            // with more than MAX (when they can), then this code is wrong because it would
408            // prevent any caller (eg future hsdir code in arti relay) from seeing the violation.
409            if intro_points.len() < NUM_INTRO_POINT_MAX {
410                intro_points.push(IntroPointDesc {
411                    link_specifiers,
412                    ipt_ntor_key: ntor_onion_key,
413                    ipt_sid_key: auth_key,
414                    svc_ntor_key,
415                });
416            }
417        }
418
419        // TODO SPEC: Might a HS publish descriptor with no IPTs to declare itself down?
420        // If it might, then we should:
421        //   - accept such descriptors here
422        //   - check for this situation explicitly in tor-hsclient connect.rs intro_rend_connect
423        //   - bail with a new `ConnError` (with ErrorKind OnionServiceNotRunning)
424        // with the consequence that once we obtain such a descriptor,
425        // we'll be satisfied with it and consider the HS down until the descriptor expires.
426        if intro_points.is_empty() {
427            return Err(EK::MissingEntry.with_msg("no introduction points"));
428        }
429
430        let inner = HsDescInner {
431            intro_auth_types: auth_types,
432            single_onion_service: is_single_onion_service,
433            pow_params,
434            intro_points,
435        };
436        let sig_gated = SignatureGated::new(inner, signatures);
437        let time_bound = match expirations.iter().min() {
438            Some(t) => TimerangeBound::new(sig_gated, ..t),
439            None => TimerangeBound::new(sig_gated, ..),
440        };
441
442        Ok((cert_signing_key, time_bound))
443    }
444}
445
446#[cfg(test)]
447mod test {
448    // @@ begin test lint list maintained by maint/add_warning @@
449    #![allow(clippy::bool_assert_comparison)]
450    #![allow(clippy::clone_on_copy)]
451    #![allow(clippy::dbg_macro)]
452    #![allow(clippy::mixed_attributes_style)]
453    #![allow(clippy::print_stderr)]
454    #![allow(clippy::print_stdout)]
455    #![allow(clippy::single_char_pattern)]
456    #![allow(clippy::unwrap_used)]
457    #![allow(clippy::unchecked_time_subtraction)]
458    #![allow(clippy::useless_vec)]
459    #![allow(clippy::needless_pass_by_value)]
460    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
461
462    use std::{iter, time::Duration};
463
464    use hex_literal::hex;
465    use itertools::chain;
466    use tor_checkable::{SelfSigned, Timebound};
467
468    use super::*;
469    use crate::doc::hsdesc::{
470        middle::HsDescMiddle,
471        outer::HsDescOuter,
472        pow::PowParams,
473        test_data::{TEST_DATA, TEST_SUBCREDENTIAL},
474    };
475
476    /// Test one particular canned 'inner' document, checking
477    /// edge cases for zero intro points and too many intro points
478    #[test]
479    fn inner_text() {
480        // This is the inner document from hsdesc1.txt aka TEST_DATA
481        const TEST_DATA_INNER: &str = include_str!("../../../testdata/hsdesc-inner.txt");
482
483        use crate::NetdocErrorKind as NEK;
484        let _desc = HsDescInner::parse(TEST_DATA_INNER).unwrap();
485
486        let none = format!(
487            "{}\n",
488            TEST_DATA_INNER
489                .split_once("\nintroduction-point")
490                .unwrap()
491                .0,
492        );
493        let err = HsDescInner::parse(&none).map(|_| &none).unwrap_err();
494        assert_eq!(err.kind, NEK::MissingEntry);
495
496        let ipt = format!(
497            "introduction-point{}",
498            TEST_DATA_INNER
499                .rsplit_once("\nintroduction-point")
500                .unwrap()
501                .1,
502        );
503        for n in NUM_INTRO_POINT_MAX..NUM_INTRO_POINT_MAX + 2 {
504            let many =
505                chain!(iter::once(&*none), std::iter::repeat_n(&*ipt, n),).collect::<String>();
506            let desc = HsDescInner::parse(&many).unwrap();
507            let desc = desc
508                .1
509                .dangerously_into_parts()
510                .0
511                .dangerously_assume_wellsigned();
512            assert_eq!(desc.intro_points.len(), NUM_INTRO_POINT_MAX);
513        }
514    }
515
516    /// Test parseability of an inner document generated by C tor with PoW v1
517    #[test]
518    #[cfg(feature = "hs-pow-full")]
519    fn inner_c_pow_v1() {
520        const TEST_DATA_INNER: &str = include_str!("../../../testdata/hsdesc-inner-pow-v1.txt");
521        let desc = HsDescInner::parse(TEST_DATA_INNER).unwrap();
522        let pow_params = desc
523            .1
524            .dangerously_into_parts()
525            .0
526            .dangerously_assume_wellsigned()
527            .pow_params;
528        assert_eq!(pow_params.slice().len(), 1);
529        match &pow_params.slice()[0] {
530            PowParams::V1(v1) => {
531                let expected_effort: tor_hscrypto::pow::v1::Effort = 614.into();
532                let expected_seed: tor_hscrypto::pow::v1::Seed =
533                    hex!("144e901df0841833a6e8592190849b4412f307d1565f2f137b2a5bc21a31092a").into();
534                let expected_expiry = Some(SystemTime::UNIX_EPOCH + Duration::new(1712812537, 0));
535                assert_eq!(v1.suggested_effort(), expected_effort);
536                assert_eq!(
537                    v1.seed().to_owned().dangerously_assume_timely(),
538                    expected_seed
539                );
540                assert_eq!(v1.seed().bounds().1, expected_expiry);
541            }
542            #[allow(unreachable_patterns)]
543            _ => unreachable!(),
544        }
545    }
546
547    /// Ensure the same valid v1 pow document parses with the addition of unknown schemes
548    #[test]
549    fn inner_c_pow_v1_with_unknown() {
550        const TEMPLATE: &str = include_str!("../../../testdata/hsdesc-inner-pow-v1.txt");
551        let parts = TEMPLATE.rsplit_once("\npow-params").unwrap();
552        let test_data_inner = format!("{}\npow-params x-example\npow-params{}", parts.0, parts.1);
553        let desc = HsDescInner::parse(&test_data_inner).unwrap();
554        let pow_params = desc
555            .1
556            .dangerously_into_parts()
557            .0
558            .dangerously_assume_wellsigned()
559            .pow_params;
560        assert_eq!(pow_params.slice().len(), 1);
561    }
562
563    /// Incorrect reduced document with a pow-params line that has no scheme parameter
564    #[test]
565    fn inner_pow_empty() {
566        const TEST_DATA_INNER: &str = include_str!("../../../testdata/hsdesc-inner-pow-empty.txt");
567        let err = HsDescInner::parse(TEST_DATA_INNER).map(|_| ()).unwrap_err();
568        assert_eq!(err.kind, crate::NetdocErrorKind::TooFewArguments);
569    }
570
571    /// Incorrect document with duplicated pow-params lines of the same known type
572    #[test]
573    fn inner_pow_duplicate() {
574        // Modify the canned v1 pow example from c tor, by duplicating the entire pow-params line
575        const TEMPLATE: &str = include_str!("../../../testdata/hsdesc-inner-pow-v1.txt");
576        let first_split = TEMPLATE.rsplit_once("\npow-params").unwrap();
577        let second_split = first_split.1.split_once("\n").unwrap();
578        let test_data_inner = format!(
579            "{}\npow-params{}\npow-params{}\n{}",
580            first_split.0, second_split.0, second_split.0, second_split.1
581        );
582        let err = HsDescInner::parse(&test_data_inner)
583            .map(|_| ())
584            .unwrap_err();
585        assert_eq!(err.kind, crate::NetdocErrorKind::DuplicateToken);
586    }
587
588    /// Incorrect document with an unexpected object encoded after the pow v1 scheme's pow-params
589    #[test]
590    #[cfg(feature = "hs-pow-full")]
591    fn inner_pow_v1_object() {
592        // Modify the canned v1 pow example
593        const TEMPLATE: &str = include_str!("../../../testdata/hsdesc-inner-pow-v1.txt");
594        let first_split = TEMPLATE.rsplit_once("\npow-params").unwrap();
595        let second_split = first_split.1.split_once("\n").unwrap();
596        let test_data_inner = format!(
597            "{}\npow-params{}\n-----BEGIN THING-----\n-----END THING-----\n{}",
598            first_split.0, second_split.0, second_split.1
599        );
600        let err = HsDescInner::parse(&test_data_inner)
601            .map(|_| ())
602            .unwrap_err();
603        assert_eq!(err.kind, crate::NetdocErrorKind::UnexpectedObject);
604    }
605
606    /// Document including an unrecognized pow-params line, ignored without error and not
607    /// represented in the output at all.
608    ///
609    /// Also tests that unrecognized schemes are not subject to a restriction against
610    /// duplicate appearances. (The spec allows that implementations do not need to
611    /// implement this prohibition for arbitrary scheme strings)
612    ///
613    /// TODO: We may want PowParamSet to provide a representation for arbitrary unknown PoW
614    ///       schemes, to the extent that this information may be useful for error reporting
615    ///       purposes after an onion service rendezvous fails.
616    #[test]
617    fn inner_pow_unrecognized() {
618        // Use the reduced document from inner_pow_empty() as a template
619        const TEMPLATE: &str = include_str!("../../../testdata/hsdesc-inner-pow-empty.txt");
620        let parts = TEMPLATE.rsplit_once("\npow-params").unwrap();
621        let test_data_inner = format!(
622            "{}\npow-params x-example\npow-params x-example{}",
623            parts.0, parts.1
624        );
625        let desc = HsDescInner::parse(&test_data_inner).unwrap();
626        let pow_params = desc
627            .1
628            .dangerously_into_parts()
629            .0
630            .dangerously_assume_wellsigned()
631            .pow_params;
632        assert_eq!(pow_params.slice().len(), 0);
633    }
634
635    /// Document with an unrecognized pow-params line including an object
636    #[test]
637    fn inner_pow_unrecognized_object() {
638        // Use the reduced document from inner_pow_empty() as a template
639        const TEMPLATE: &str = include_str!("../../../testdata/hsdesc-inner-pow-empty.txt");
640        let parts = TEMPLATE.rsplit_once("\npow-params").unwrap();
641        let test_data_inner = format!(
642            "{}\npow-params x-something-else with args\n-----BEGIN THING-----\n-----END THING-----{}",
643            parts.0, parts.1
644        );
645        let desc = HsDescInner::parse(&test_data_inner).unwrap();
646        let pow_params = desc
647            .1
648            .dangerously_into_parts()
649            .0
650            .dangerously_assume_wellsigned()
651            .pow_params;
652        assert_eq!(pow_params.slice().len(), 0);
653    }
654
655    #[test]
656    fn parse_good() -> Result<()> {
657        let desc = HsDescOuter::parse(TEST_DATA)?
658            .dangerously_assume_wellsigned()
659            .dangerously_assume_timely();
660        let subcred = TEST_SUBCREDENTIAL.into();
661        let body = desc.decrypt_body(&subcred).unwrap();
662        let body = std::str::from_utf8(&body[..]).unwrap();
663
664        let middle = HsDescMiddle::parse(body)?;
665        let inner_body = middle
666            .decrypt_inner(&desc.blinded_id(), desc.revision_counter(), &subcred, None)
667            .unwrap();
668        let inner_body = std::str::from_utf8(&inner_body).unwrap();
669        let (ed_id, inner) = HsDescInner::parse(inner_body)?;
670        let inner = inner
671            .check_valid_at(&humantime::parse_rfc3339("2023-01-23T15:00:00Z").unwrap())
672            .unwrap()
673            .check_signature()
674            .unwrap();
675
676        assert_eq!(ed_id.as_ref(), Some(desc.desc_sign_key_id()));
677
678        assert!(inner.intro_auth_types.is_none());
679        assert_eq!(inner.single_onion_service, false);
680        assert_eq!(inner.intro_points.len(), 3);
681
682        let ipt0 = &inner.intro_points[0];
683        assert_eq!(
684            ipt0.ipt_ntor_key().as_bytes(),
685            &hex!("553BF9F9E1979D6F5D5D7D20BB3FE7272E32E22B6E86E35C76A7CA8A377E402F")
686        );
687
688        assert_ne!(ipt0.link_specifiers, inner.intro_points[1].link_specifiers);
689
690        Ok(())
691    }
692}