Skip to main content

offline_protocol_mls/
discovery.rs

1//! `DiscoveryRecordV1`: a signed, non-authoritative claim that a username
2//! points at an address.
3//!
4//! # A claim, never a fact
5//!
6//! Anyone may publish any username claim at the right tag. There is no
7//! first-publisher-wins on a Nostr relay and this layer does not invent one.
8//! Every record says exactly one thing: *this key asserts this name*. What a
9//! record cannot do is lie about a **key**, because
10//! `derive_address(pubkey) == address` is checked on every one — so the worst a
11//! squatter achieves is to offer you a name attached to their own real identity,
12//! which is precisely NIP-05's threat model and precisely why its "identify,
13//! never verify" semantics are the right ones to adopt.
14//!
15//! The consequence is normative and it belongs at the top of this file: a
16//! resolver MUST surface the whole set of claimants and let a human arbitrate.
17//! A UI that silently picks the first result has converted a deliberately
18//! non-authoritative directory into an authoritative-looking one, which is worse
19//! than not shipping it at all. See `docs/spec/username-discovery.md`.
20//!
21//! # Why a username resolves to a set, always
22//!
23//! One record per device. Each install signs its own record with its own
24//! identity key and publishes it under its own Nostr key, so all of a user's
25//! devices coexist at the same tag (addressable replacement is keyed on
26//! `(kind, pubkey, d)`, and each device is a different `pubkey`). A resolver
27//! queries once and receives the whole set.
28//!
29//! This is not a limitation to be designed around: no device knows the
30//! addresses of its siblings — there is no identity export, import, backup or
31//! sync API anywhere in this SDK, by construction — so a record shaped as
32//! `{username, [devices]}` could not be produced at all. Aggregating at the tag
33//! reaches the identical result with zero coordination. A username is therefore
34//! 1:N even for a single-device user, who is simply a set of one.
35//!
36//! # The `nostr_author` binding
37//!
38//! Step 5 of verification is what this record has and the published key-package
39//! record does not, and it closes a residual that is materially worse for a
40//! directory than for a key package.
41//!
42//! Because the seal key is publicly derivable, a squatter can unseal a record,
43//! re-seal the untouched (and genuinely signed) payload under their own author
44//! key, and republish it. For a key package the cost is a dead session and one
45//! exchange of delay. For a directory entry it defeats **retraction**:
46//! addressable replacement is per-`pubkey`, so the owner's tombstone replaces
47//! only the owner's own event and never a copy standing under someone else's
48//! key. That would keep a rotated-away or compromised address in the directory
49//! indefinitely. Binding the author key inside the signed payload makes a
50//! re-authored copy fail outright.
51
52use offline_protocol_core::{Address, Username};
53use serde::{Deserialize, Serialize};
54
55use crate::canonical::canonical_payload;
56use crate::error::{MlsError, Result};
57use crate::manager::MlsManager;
58
59/// Signature domain for discovery records.
60///
61/// Must not be a prefix of, or prefixed by, any other live signing domain. See
62/// the `canonical` module.
63pub const DISCOVERY_SIGN_DOMAIN: &[u8] = b"offline-disc-v1";
64
65/// The only discovery record version this build produces or accepts.
66pub const DISCOVERY_VERSION: u8 = 1;
67
68/// Length of an Ed25519 public key.
69const PUBLIC_KEY_LEN: usize = 32;
70
71/// Length of an Ed25519 signature.
72const SIGNATURE_LEN: usize = 64;
73
74/// Length of a Nostr x-only public key.
75const NOSTR_KEY_LEN: usize = 32;
76
77/// A signed claim that a username points at an address.
78///
79/// Serialized as JSON inside a sealed relay record. The byte-level contract is
80/// [`Self::signing_payload`], not this struct's JSON: two implementations must
81/// agree on the signed bytes, and may disagree on JSON key order.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct DiscoveryRecordV1 {
84    /// Format version. Always [`DISCOVERY_VERSION`] in this build.
85    pub v: u8,
86    /// The claimed name, in normalized form.
87    ///
88    /// Typed, so a record naming an unnormalized username fails to deserialize
89    /// rather than being compared against a queried name it can never equal.
90    pub username: Username,
91    /// The address the claim points at.
92    pub address: Address,
93    /// The Ed25519 identity key. `derive_address(pubkey)` must equal `address`.
94    #[serde(with = "base64_bytes")]
95    pub pubkey: Vec<u8>,
96    /// The x-only Nostr key this record is valid when published under.
97    ///
98    /// See the module docs: this is what stops a third party from keeping a
99    /// retracted claim alive.
100    #[serde(with = "hex_bytes")]
101    pub nostr_author: Vec<u8>,
102    /// Signing time, in milliseconds since the Unix epoch.
103    ///
104    /// **Advisory only.** See [`verify_discovery_record`].
105    pub issued_at_ms: i64,
106    /// Ed25519 signature by `pubkey` over [`Self::signing_payload`].
107    #[serde(with = "base64_bytes")]
108    pub sig: Vec<u8>,
109}
110
111/// A tombstone: the body a retraction publishes in place of a claim.
112///
113/// Retraction republishes the same `(kind, pubkey, d)` with this body and a
114/// fresh `created_at`, plus a best-effort NIP-09 deletion. It is best-effort by
115/// nature — a relay may honour neither — which is one more reason staleness is
116/// advisory and the key-package fetch arbitrates.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct DiscoveryTombstoneV1 {
119    /// Format version. Always [`DISCOVERY_VERSION`].
120    pub v: u8,
121    /// Always `true`. Present so a tombstone cannot be mistaken for a record
122    /// whose fields failed to parse.
123    pub retracted: bool,
124}
125
126impl DiscoveryTombstoneV1 {
127    /// Builds a tombstone body.
128    pub fn new() -> Self {
129        Self {
130            v: DISCOVERY_VERSION,
131            retracted: true,
132        }
133    }
134}
135
136impl Default for DiscoveryTombstoneV1 {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142/// What a resolved record body turned out to be.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum DiscoveryBody {
145    /// A claim, not yet verified.
146    Record(Box<DiscoveryRecordV1>),
147    /// A retraction. The claimant is withdrawing the name.
148    Tombstone,
149}
150
151/// Parses a record body, distinguishing a claim from a retraction.
152///
153/// A body that is neither is an error, which the caller drops: a public tag
154/// returns whatever the relay holds there, so junk is ordinary rather than
155/// exceptional.
156pub fn parse_discovery_body(bytes: &[u8]) -> Result<DiscoveryBody> {
157    // Tombstone first: it is the narrower shape, and a record can never satisfy
158    // it because `retracted` is required and has no counterpart in a claim.
159    if let Ok(tombstone) = serde_json::from_slice::<DiscoveryTombstoneV1>(bytes) {
160        if tombstone.retracted {
161            return Ok(DiscoveryBody::Tombstone);
162        }
163    }
164    let record: DiscoveryRecordV1 = serde_json::from_slice(bytes)
165        .map_err(|e| MlsError::Deserialization(format!("Discovery record is malformed: {}", e)))?;
166    Ok(DiscoveryBody::Record(Box::new(record)))
167}
168
169impl DiscoveryRecordV1 {
170    /// Builds the payload the signature is taken over.
171    ///
172    /// `domain ‖ u32be‖bytes` over
173    /// `[v, username, address, pubkey, nostr_author, issued_at_ms]`, in that
174    /// fixed order. `issued_at_ms` is encoded as its 8-byte big-endian form,
175    /// not as a decimal string, so two implementations cannot disagree about
176    /// leading zeroes or a sign.
177    pub fn signing_payload(
178        username: &Username,
179        address: &Address,
180        pubkey: &[u8],
181        nostr_author: &[u8],
182        issued_at_ms: i64,
183    ) -> Result<Vec<u8>> {
184        let address_string = address.to_string();
185        canonical_payload(
186            DISCOVERY_SIGN_DOMAIN,
187            &[
188                &[DISCOVERY_VERSION],
189                username.as_str().as_bytes(),
190                address_string.as_bytes(),
191                pubkey,
192                nostr_author,
193                &issued_at_ms.to_be_bytes(),
194            ],
195        )
196    }
197
198    /// Builds an unsigned record, ready for [`Self::sign_with`].
199    pub fn unsigned(
200        username: Username,
201        address: Address,
202        pubkey: Vec<u8>,
203        nostr_author: Vec<u8>,
204        issued_at_ms: i64,
205    ) -> Self {
206        Self {
207            v: DISCOVERY_VERSION,
208            username,
209            address,
210            pubkey,
211            nostr_author,
212            issued_at_ms,
213            sig: Vec::new(),
214        }
215    }
216
217    /// Signs the record with `signer`, which must be the private half of
218    /// `self.pubkey`.
219    ///
220    /// Taking a closure keeps the private key where it lives (the engine's MLS
221    /// manager) rather than passing key material into this module.
222    pub fn sign_with<F>(mut self, signer: F) -> Result<Self>
223    where
224        F: FnOnce(&[u8]) -> Result<Vec<u8>>,
225    {
226        let payload = Self::signing_payload(
227            &self.username,
228            &self.address,
229            &self.pubkey,
230            &self.nostr_author,
231            self.issued_at_ms,
232        )?;
233        self.sig = signer(&payload)?;
234        Ok(self)
235    }
236}
237
238/// Why a discovery record was rejected.
239///
240/// Separate from [`MlsError`] because every variant here is an *ordinary*
241/// outcome of querying a public tag, not a fault: a resolver drops the record
242/// and keeps going. Carrying them as errors would make junk at a public tag
243/// look like a failure of the system reading it.
244#[non_exhaustive]
245#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
246pub enum DiscoveryRejection {
247    /// The version is not one this build understands.
248    #[error("discovery record version is {found}, expected {expected}")]
249    UnsupportedVersion {
250        /// The version the record carried.
251        found: u8,
252        /// The only version this build accepts.
253        expected: u8,
254    },
255    /// A fixed-length field is the wrong size.
256    #[error("discovery record field '{field}' is {len} bytes, expected {expected}")]
257    FieldLength {
258        /// Which field.
259        field: &'static str,
260        /// Length found.
261        len: usize,
262        /// Length required.
263        expected: usize,
264    },
265    /// The record claims a different name than the one queried.
266    ///
267    /// A record is only meaningful at its own tag, so this also catches a
268    /// record that was mis-tagged or copied to a foreign tag.
269    #[error("discovery record claims '{claimed}' but '{queried}' was queried")]
270    UsernameMismatch {
271        /// The name the record claims.
272        claimed: String,
273        /// The name that was resolved.
274        queried: String,
275    },
276    /// `derive_address(pubkey)` is not the claimed address.
277    #[error("discovery record address is not the address its public key derives to")]
278    AddressNotDerived,
279    /// The signature does not verify under `pubkey`.
280    #[error("discovery record signature does not verify")]
281    BadSignature,
282    /// The record was published under a different Nostr key than it names.
283    ///
284    /// This is a re-authored copy: someone unsealed a genuine record and stood
285    /// it back up under their own key. See the module docs.
286    #[error("discovery record was published under a foreign Nostr key")]
287    ForeignAuthor,
288}
289
290/// Verifies a discovery record against the username it was queried for and the
291/// Nostr key it was published under.
292///
293/// The checks run in the order the design fixes, cheap before expensive:
294///
295/// 1. `v == 1` and every fixed-length field is the right size;
296/// 2. the username matches the queried name exactly;
297/// 3. `derive_address(pubkey) == address`;
298/// 4. the Ed25519 signature verifies under `pubkey`;
299/// 5. `nostr_author == event.pubkey`.
300///
301/// # Staleness is advisory and is deliberately not checked here
302///
303/// A record is not a liveness signal; the key-package fetch that follows it is.
304/// A stale record whose key packages are gone fails at that fetch, which is the
305/// honest place to fail. Rejecting on age would instead make a peer who has
306/// been offline for a month unreachable *by name* while their key packages sit
307/// valid on a relay for 30 days. Surface `issued_at_ms` to the app, let it sort,
308/// and let the fetch arbitrate.
309pub fn verify_discovery_record(
310    record: &DiscoveryRecordV1,
311    queried: &Username,
312    event_author: &[u8],
313) -> std::result::Result<(), DiscoveryRejection> {
314    if record.v != DISCOVERY_VERSION {
315        return Err(DiscoveryRejection::UnsupportedVersion {
316            found: record.v,
317            expected: DISCOVERY_VERSION,
318        });
319    }
320    if record.pubkey.len() != PUBLIC_KEY_LEN {
321        return Err(DiscoveryRejection::FieldLength {
322            field: "pubkey",
323            len: record.pubkey.len(),
324            expected: PUBLIC_KEY_LEN,
325        });
326    }
327    if record.nostr_author.len() != NOSTR_KEY_LEN {
328        return Err(DiscoveryRejection::FieldLength {
329            field: "nostr_author",
330            len: record.nostr_author.len(),
331            expected: NOSTR_KEY_LEN,
332        });
333    }
334    if record.sig.len() != SIGNATURE_LEN {
335        return Err(DiscoveryRejection::FieldLength {
336            field: "sig",
337            len: record.sig.len(),
338            expected: SIGNATURE_LEN,
339        });
340    }
341
342    if record.username != *queried {
343        return Err(DiscoveryRejection::UsernameMismatch {
344            claimed: record.username.as_str().to_string(),
345            queried: queried.as_str().to_string(),
346        });
347    }
348
349    let derived = MlsManager::derive_address(&record.pubkey)
350        .map_err(|_| DiscoveryRejection::AddressNotDerived)?;
351    if derived != record.address {
352        return Err(DiscoveryRejection::AddressNotDerived);
353    }
354
355    let payload = DiscoveryRecordV1::signing_payload(
356        &record.username,
357        &record.address,
358        &record.pubkey,
359        &record.nostr_author,
360        record.issued_at_ms,
361    )
362    .map_err(|_| DiscoveryRejection::BadSignature)?;
363
364    match MlsManager::verify_signature(&record.pubkey, &payload, &record.sig) {
365        Ok(true) => {}
366        _ => return Err(DiscoveryRejection::BadSignature),
367    }
368
369    // Last, because it is the check an honest record passes trivially and a
370    // re-authored one fails: everything above verifies against the *record*,
371    // and this is the only step that involves the event carrying it.
372    if record.nostr_author != event_author {
373        return Err(DiscoveryRejection::ForeignAuthor);
374    }
375
376    Ok(())
377}
378
379/// Base64 for byte fields that are naturally binary.
380mod base64_bytes {
381    use base64::engine::general_purpose::STANDARD;
382    use base64::Engine;
383    use serde::{Deserialize, Deserializer, Serializer};
384
385    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
386        serializer.serialize_str(&STANDARD.encode(bytes))
387    }
388
389    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
390        let encoded = String::deserialize(deserializer)?;
391        STANDARD.decode(&encoded).map_err(serde::de::Error::custom)
392    }
393}
394
395/// Hex for the Nostr author key, which is hex everywhere else in the Nostr
396/// layer — matching it here means a comparison never crosses an encoding.
397mod hex_bytes {
398    use serde::{Deserialize, Deserializer, Serializer};
399
400    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
401        serializer.serialize_str(&hex::encode(bytes))
402    }
403
404    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
405        let encoded = String::deserialize(deserializer)?;
406        hex::decode(&encoded).map_err(serde::de::Error::custom)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use base64::Engine as _;
414    use ed25519_dalek::{Signer, SigningKey};
415
416    fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
417        let signing = SigningKey::from_bytes(&[seed; 32]);
418        let public = signing.verifying_key().to_bytes().to_vec();
419        let address = MlsManager::derive_address(&public).expect("derive");
420        (signing, public, address)
421    }
422
423    fn username(s: &str) -> Username {
424        s.parse().expect("username")
425    }
426
427    fn signed_record(seed: u8, name: &str, author: &[u8]) -> DiscoveryRecordV1 {
428        let (signing, public, address) = identity(seed);
429        DiscoveryRecordV1::unsigned(
430            username(name),
431            address,
432            public,
433            author.to_vec(),
434            1_700_000_000_000,
435        )
436        .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
437        .expect("sign")
438    }
439
440    fn author(seed: u8) -> Vec<u8> {
441        vec![seed; NOSTR_KEY_LEN]
442    }
443
444    #[test]
445    fn discovery_record_verifies() {
446        let author = author(9);
447        let record = signed_record(1, "alice", &author);
448        assert_eq!(
449            verify_discovery_record(&record, &username("alice"), &author),
450            Ok(())
451        );
452    }
453
454    #[test]
455    fn discovery_record_round_trips_through_json() {
456        let author = author(9);
457        let record = signed_record(1, "alice", &author);
458        let json = serde_json::to_vec(&record).expect("serialize");
459        match parse_discovery_body(&json).expect("parse") {
460            DiscoveryBody::Record(parsed) => assert_eq!(*parsed, record),
461            DiscoveryBody::Tombstone => panic!("a record must not parse as a tombstone"),
462        }
463    }
464
465    #[test]
466    fn discovery_tombstone_parses_as_a_retraction() {
467        let json = serde_json::to_vec(&DiscoveryTombstoneV1::new()).expect("serialize");
468        assert_eq!(
469            parse_discovery_body(&json).expect("parse"),
470            DiscoveryBody::Tombstone
471        );
472    }
473
474    /// The negative control for the `nostr_author` binding, and the single most
475    /// important test in this module: a squatter unseals a genuine record and
476    /// republishes it verbatim under their own key. Everything inside the
477    /// record still verifies — the signature is real — and the record must
478    /// still be refused.
479    #[test]
480    fn discovery_record_refuses_a_re_authored_copy() {
481        let genuine_author = author(9);
482        let record = signed_record(1, "alice", &genuine_author);
483
484        // Untouched payload, republished under the squatter's key.
485        let squatter_author = author(200);
486        assert_eq!(
487            verify_discovery_record(&record, &username("alice"), &squatter_author),
488            Err(DiscoveryRejection::ForeignAuthor)
489        );
490    }
491
492    /// The squatter's other option — re-sign the record naming their own author
493    /// key — must fail too, because they do not hold the identity key.
494    #[test]
495    fn discovery_record_refuses_a_rebound_author_without_the_identity_key() {
496        let record = signed_record(1, "alice", &author(9));
497        let mut rebound = record.clone();
498        rebound.nostr_author = author(200);
499        assert_eq!(
500            verify_discovery_record(&rebound, &username("alice"), &author(200)),
501            Err(DiscoveryRejection::BadSignature)
502        );
503    }
504
505    #[test]
506    fn discovery_record_refuses_a_foreign_address() {
507        let author = author(9);
508        let mut record = signed_record(1, "alice", &author);
509        let (_, _, other) = identity(2);
510        record.address = other;
511        assert_eq!(
512            verify_discovery_record(&record, &username("alice"), &author),
513            Err(DiscoveryRejection::AddressNotDerived)
514        );
515    }
516
517    /// A record is only meaningful at its own tag. This is what catches a
518    /// genuine record for `bob` copied to `alice`'s tag.
519    #[test]
520    fn discovery_record_refuses_a_username_it_was_not_queried_for() {
521        let author = author(9);
522        let record = signed_record(1, "bob", &author);
523        assert_eq!(
524            verify_discovery_record(&record, &username("alice"), &author),
525            Err(DiscoveryRejection::UsernameMismatch {
526                claimed: "bob".to_string(),
527                queried: "alice".to_string(),
528            })
529        );
530    }
531
532    #[test]
533    fn discovery_record_refuses_a_tampered_username() {
534        let author = author(9);
535        let mut record = signed_record(1, "alice", &author);
536        record.username = username("mallory");
537        assert_eq!(
538            verify_discovery_record(&record, &username("mallory"), &author),
539            Err(DiscoveryRejection::BadSignature)
540        );
541    }
542
543    #[test]
544    fn discovery_record_refuses_a_tampered_issue_time() {
545        let author = author(9);
546        let mut record = signed_record(1, "alice", &author);
547        record.issued_at_ms += 1;
548        assert_eq!(
549            verify_discovery_record(&record, &username("alice"), &author),
550            Err(DiscoveryRejection::BadSignature)
551        );
552    }
553
554    #[test]
555    fn discovery_record_refuses_an_unsupported_version() {
556        let author = author(9);
557        let mut record = signed_record(1, "alice", &author);
558        record.v = 2;
559        assert!(matches!(
560            verify_discovery_record(&record, &username("alice"), &author),
561            Err(DiscoveryRejection::UnsupportedVersion { .. })
562        ));
563    }
564
565    #[test]
566    fn discovery_record_refuses_wrong_length_fields() {
567        let author = author(9);
568        for mutate in [
569            (|r: &mut DiscoveryRecordV1| r.pubkey.truncate(31)) as fn(&mut DiscoveryRecordV1),
570            |r: &mut DiscoveryRecordV1| r.nostr_author.truncate(31),
571            |r: &mut DiscoveryRecordV1| r.sig.truncate(63),
572        ] {
573            let mut record = signed_record(1, "alice", &author);
574            mutate(&mut record);
575            assert!(matches!(
576                verify_discovery_record(&record, &username("alice"), &author),
577                Err(DiscoveryRejection::FieldLength { .. })
578            ));
579        }
580    }
581
582    /// Staleness must not reject. A record signed long ago still verifies; the
583    /// key-package fetch is what decides whether the peer is reachable.
584    #[test]
585    fn discovery_record_accepts_an_old_issue_time() {
586        let author = author(9);
587        let (signing, public, address) = identity(1);
588        let ancient = DiscoveryRecordV1::unsigned(
589            username("alice"),
590            address,
591            public,
592            author.clone(),
593            1_000_000_000_000,
594        )
595        .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
596        .expect("sign");
597
598        assert_eq!(
599            verify_discovery_record(&ancient, &username("alice"), &author),
600            Ok(())
601        );
602    }
603
604    /// A record naming an unnormalized username must not deserialize: the
605    /// repaired form would verify against a tag the record was never published
606    /// at, which is the silent-miss failure the `Username` type exists to stop.
607    #[test]
608    fn discovery_record_refuses_an_unnormalized_username_on_the_wire() {
609        let author = author(9);
610        let record = signed_record(1, "alice", &author);
611        let mut value: serde_json::Value =
612            serde_json::from_slice(&serde_json::to_vec(&record).expect("serialize"))
613                .expect("value");
614        value["username"] = serde_json::Value::String("Alice".to_string());
615        let json = serde_json::to_vec(&value).expect("reserialize");
616        assert!(parse_discovery_body(&json).is_err());
617    }
618
619    /// Golden vector for the signed bytes. A second implementation must produce
620    /// this payload exactly, or the two sign different things and every
621    /// cross-implementation verification fails — silently, since a bad
622    /// signature is indistinguishable from a squatted record.
623    ///
624    /// **Computed independently of this code** by a Python script that builds
625    /// the payload from the written construction and derives the address with
626    /// the BIP-350 reference bech32m. Regenerate it the same way.
627    ///
628    /// The identity is the all-`0x01` Ed25519 seed and the author key is 32
629    /// bytes of `0x09`, so a second implementation can reproduce it without
630    /// this repository.
631    #[test]
632    fn discovery_signing_payload_golden_vector() {
633        let (_, public, address) = identity(1);
634        assert_eq!(
635            address.to_string(),
636            "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
637            "the golden identity's address changed"
638        );
639
640        let payload = DiscoveryRecordV1::signing_payload(
641            &username("alice"),
642            &address,
643            &public,
644            &author(9),
645            1_700_000_000_000,
646        )
647        .expect("payload");
648
649        assert_eq!(
650            base64::engine::general_purpose::STANDARD.encode(&payload),
651            "b2ZmbGluZS1kaXNjLXYxAAAAAQEAAAAFYWxpY2UAAAAsb2ZmMXF5NjgycnVjaDR2bGVseTVka2o5NDI0N2p2YTd6NDl5azV4cHFlZTAAAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAgCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkAAAAIAAABi8/laAA="
652        );
653    }
654}