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::error::{MlsError, Result};
56use crate::manager::MlsManager;
57use offline_protocol_sealed::canonical_payload;
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/// `offline_protocol_sealed::canonical`.
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        .map_err(MlsError::from)
197    }
198
199    /// Builds an unsigned record, ready for [`Self::sign_with`].
200    pub fn unsigned(
201        username: Username,
202        address: Address,
203        pubkey: Vec<u8>,
204        nostr_author: Vec<u8>,
205        issued_at_ms: i64,
206    ) -> Self {
207        Self {
208            v: DISCOVERY_VERSION,
209            username,
210            address,
211            pubkey,
212            nostr_author,
213            issued_at_ms,
214            sig: Vec::new(),
215        }
216    }
217
218    /// Signs the record with `signer`, which must be the private half of
219    /// `self.pubkey`.
220    ///
221    /// Taking a closure keeps the private key where it lives (the engine's MLS
222    /// manager) rather than passing key material into this module.
223    pub fn sign_with<F>(mut self, signer: F) -> Result<Self>
224    where
225        F: FnOnce(&[u8]) -> Result<Vec<u8>>,
226    {
227        let payload = Self::signing_payload(
228            &self.username,
229            &self.address,
230            &self.pubkey,
231            &self.nostr_author,
232            self.issued_at_ms,
233        )?;
234        self.sig = signer(&payload)?;
235        Ok(self)
236    }
237}
238
239/// Why a discovery record was rejected.
240///
241/// Separate from [`MlsError`] because every variant here is an *ordinary*
242/// outcome of querying a public tag, not a fault: a resolver drops the record
243/// and keeps going. Carrying them as errors would make junk at a public tag
244/// look like a failure of the system reading it.
245#[non_exhaustive]
246#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
247pub enum DiscoveryRejection {
248    /// The version is not one this build understands.
249    #[error("discovery record version is {found}, expected {expected}")]
250    UnsupportedVersion {
251        /// The version the record carried.
252        found: u8,
253        /// The only version this build accepts.
254        expected: u8,
255    },
256    /// A fixed-length field is the wrong size.
257    #[error("discovery record field '{field}' is {len} bytes, expected {expected}")]
258    FieldLength {
259        /// Which field.
260        field: &'static str,
261        /// Length found.
262        len: usize,
263        /// Length required.
264        expected: usize,
265    },
266    /// The record claims a different name than the one queried.
267    ///
268    /// A record is only meaningful at its own tag, so this also catches a
269    /// record that was mis-tagged or copied to a foreign tag.
270    #[error("discovery record claims '{claimed}' but '{queried}' was queried")]
271    UsernameMismatch {
272        /// The name the record claims.
273        claimed: String,
274        /// The name that was resolved.
275        queried: String,
276    },
277    /// `derive_address(pubkey)` is not the claimed address.
278    #[error("discovery record address is not the address its public key derives to")]
279    AddressNotDerived,
280    /// The signature does not verify under `pubkey`.
281    #[error("discovery record signature does not verify")]
282    BadSignature,
283    /// The record was published under a different Nostr key than it names.
284    ///
285    /// This is a re-authored copy: someone unsealed a genuine record and stood
286    /// it back up under their own key. See the module docs.
287    #[error("discovery record was published under a foreign Nostr key")]
288    ForeignAuthor,
289}
290
291/// Verifies a discovery record against the username it was queried for and the
292/// Nostr key it was published under.
293///
294/// The checks run in the order the design fixes, cheap before expensive:
295///
296/// 1. `v == 1` and every fixed-length field is the right size;
297/// 2. the username matches the queried name exactly;
298/// 3. `derive_address(pubkey) == address`;
299/// 4. the Ed25519 signature verifies under `pubkey`;
300/// 5. `nostr_author == event.pubkey`.
301///
302/// # Staleness is advisory and is deliberately not checked here
303///
304/// A record is not a liveness signal; the key-package fetch that follows it is.
305/// A stale record whose key packages are gone fails at that fetch, which is the
306/// honest place to fail. Rejecting on age would instead make a peer who has
307/// been offline for a month unreachable *by name* while their key packages sit
308/// valid on a relay for 30 days. Surface `issued_at_ms` to the app, let it sort,
309/// and let the fetch arbitrate.
310pub fn verify_discovery_record(
311    record: &DiscoveryRecordV1,
312    queried: &Username,
313    event_author: &[u8],
314) -> std::result::Result<(), DiscoveryRejection> {
315    if record.v != DISCOVERY_VERSION {
316        return Err(DiscoveryRejection::UnsupportedVersion {
317            found: record.v,
318            expected: DISCOVERY_VERSION,
319        });
320    }
321    if record.pubkey.len() != PUBLIC_KEY_LEN {
322        return Err(DiscoveryRejection::FieldLength {
323            field: "pubkey",
324            len: record.pubkey.len(),
325            expected: PUBLIC_KEY_LEN,
326        });
327    }
328    if record.nostr_author.len() != NOSTR_KEY_LEN {
329        return Err(DiscoveryRejection::FieldLength {
330            field: "nostr_author",
331            len: record.nostr_author.len(),
332            expected: NOSTR_KEY_LEN,
333        });
334    }
335    if record.sig.len() != SIGNATURE_LEN {
336        return Err(DiscoveryRejection::FieldLength {
337            field: "sig",
338            len: record.sig.len(),
339            expected: SIGNATURE_LEN,
340        });
341    }
342
343    if record.username != *queried {
344        return Err(DiscoveryRejection::UsernameMismatch {
345            claimed: record.username.as_str().to_string(),
346            queried: queried.as_str().to_string(),
347        });
348    }
349
350    let derived = MlsManager::derive_address(&record.pubkey)
351        .map_err(|_| DiscoveryRejection::AddressNotDerived)?;
352    if derived != record.address {
353        return Err(DiscoveryRejection::AddressNotDerived);
354    }
355
356    let payload = DiscoveryRecordV1::signing_payload(
357        &record.username,
358        &record.address,
359        &record.pubkey,
360        &record.nostr_author,
361        record.issued_at_ms,
362    )
363    .map_err(|_| DiscoveryRejection::BadSignature)?;
364
365    match MlsManager::verify_signature(&record.pubkey, &payload, &record.sig) {
366        Ok(true) => {}
367        _ => return Err(DiscoveryRejection::BadSignature),
368    }
369
370    // Last, because it is the check an honest record passes trivially and a
371    // re-authored one fails: everything above verifies against the *record*,
372    // and this is the only step that involves the event carrying it.
373    if record.nostr_author != event_author {
374        return Err(DiscoveryRejection::ForeignAuthor);
375    }
376
377    Ok(())
378}
379
380/// Base64 for byte fields that are naturally binary.
381mod base64_bytes {
382    use base64::engine::general_purpose::STANDARD;
383    use base64::Engine;
384    use serde::{Deserialize, Deserializer, Serializer};
385
386    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
387        serializer.serialize_str(&STANDARD.encode(bytes))
388    }
389
390    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
391        let encoded = String::deserialize(deserializer)?;
392        STANDARD.decode(&encoded).map_err(serde::de::Error::custom)
393    }
394}
395
396/// Hex for the Nostr author key, which is hex everywhere else in the Nostr
397/// layer — matching it here means a comparison never crosses an encoding.
398mod hex_bytes {
399    use serde::{Deserialize, Deserializer, Serializer};
400
401    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
402        serializer.serialize_str(&hex::encode(bytes))
403    }
404
405    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
406        let encoded = String::deserialize(deserializer)?;
407        hex::decode(&encoded).map_err(serde::de::Error::custom)
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use base64::Engine as _;
415    use ed25519_dalek::{Signer, SigningKey};
416
417    fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
418        let signing = SigningKey::from_bytes(&[seed; 32]);
419        let public = signing.verifying_key().to_bytes().to_vec();
420        let address = MlsManager::derive_address(&public).expect("derive");
421        (signing, public, address)
422    }
423
424    fn username(s: &str) -> Username {
425        s.parse().expect("username")
426    }
427
428    fn signed_record(seed: u8, name: &str, author: &[u8]) -> DiscoveryRecordV1 {
429        let (signing, public, address) = identity(seed);
430        DiscoveryRecordV1::unsigned(
431            username(name),
432            address,
433            public,
434            author.to_vec(),
435            1_700_000_000_000,
436        )
437        .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
438        .expect("sign")
439    }
440
441    fn author(seed: u8) -> Vec<u8> {
442        vec![seed; NOSTR_KEY_LEN]
443    }
444
445    #[test]
446    fn discovery_record_verifies() {
447        let author = author(9);
448        let record = signed_record(1, "alice", &author);
449        assert_eq!(
450            verify_discovery_record(&record, &username("alice"), &author),
451            Ok(())
452        );
453    }
454
455    #[test]
456    fn discovery_record_round_trips_through_json() {
457        let author = author(9);
458        let record = signed_record(1, "alice", &author);
459        let json = serde_json::to_vec(&record).expect("serialize");
460        match parse_discovery_body(&json).expect("parse") {
461            DiscoveryBody::Record(parsed) => assert_eq!(*parsed, record),
462            DiscoveryBody::Tombstone => panic!("a record must not parse as a tombstone"),
463        }
464    }
465
466    #[test]
467    fn discovery_tombstone_parses_as_a_retraction() {
468        let json = serde_json::to_vec(&DiscoveryTombstoneV1::new()).expect("serialize");
469        assert_eq!(
470            parse_discovery_body(&json).expect("parse"),
471            DiscoveryBody::Tombstone
472        );
473    }
474
475    /// The negative control for the `nostr_author` binding, and the single most
476    /// important test in this module: a squatter unseals a genuine record and
477    /// republishes it verbatim under their own key. Everything inside the
478    /// record still verifies — the signature is real — and the record must
479    /// still be refused.
480    #[test]
481    fn discovery_record_refuses_a_re_authored_copy() {
482        let genuine_author = author(9);
483        let record = signed_record(1, "alice", &genuine_author);
484
485        // Untouched payload, republished under the squatter's key.
486        let squatter_author = author(200);
487        assert_eq!(
488            verify_discovery_record(&record, &username("alice"), &squatter_author),
489            Err(DiscoveryRejection::ForeignAuthor)
490        );
491    }
492
493    /// The squatter's other option — re-sign the record naming their own author
494    /// key — must fail too, because they do not hold the identity key.
495    #[test]
496    fn discovery_record_refuses_a_rebound_author_without_the_identity_key() {
497        let record = signed_record(1, "alice", &author(9));
498        let mut rebound = record.clone();
499        rebound.nostr_author = author(200);
500        assert_eq!(
501            verify_discovery_record(&rebound, &username("alice"), &author(200)),
502            Err(DiscoveryRejection::BadSignature)
503        );
504    }
505
506    #[test]
507    fn discovery_record_refuses_a_foreign_address() {
508        let author = author(9);
509        let mut record = signed_record(1, "alice", &author);
510        let (_, _, other) = identity(2);
511        record.address = other;
512        assert_eq!(
513            verify_discovery_record(&record, &username("alice"), &author),
514            Err(DiscoveryRejection::AddressNotDerived)
515        );
516    }
517
518    /// A record is only meaningful at its own tag. This is what catches a
519    /// genuine record for `bob` copied to `alice`'s tag.
520    #[test]
521    fn discovery_record_refuses_a_username_it_was_not_queried_for() {
522        let author = author(9);
523        let record = signed_record(1, "bob", &author);
524        assert_eq!(
525            verify_discovery_record(&record, &username("alice"), &author),
526            Err(DiscoveryRejection::UsernameMismatch {
527                claimed: "bob".to_string(),
528                queried: "alice".to_string(),
529            })
530        );
531    }
532
533    #[test]
534    fn discovery_record_refuses_a_tampered_username() {
535        let author = author(9);
536        let mut record = signed_record(1, "alice", &author);
537        record.username = username("mallory");
538        assert_eq!(
539            verify_discovery_record(&record, &username("mallory"), &author),
540            Err(DiscoveryRejection::BadSignature)
541        );
542    }
543
544    #[test]
545    fn discovery_record_refuses_a_tampered_issue_time() {
546        let author = author(9);
547        let mut record = signed_record(1, "alice", &author);
548        record.issued_at_ms += 1;
549        assert_eq!(
550            verify_discovery_record(&record, &username("alice"), &author),
551            Err(DiscoveryRejection::BadSignature)
552        );
553    }
554
555    #[test]
556    fn discovery_record_refuses_an_unsupported_version() {
557        let author = author(9);
558        let mut record = signed_record(1, "alice", &author);
559        record.v = 2;
560        assert!(matches!(
561            verify_discovery_record(&record, &username("alice"), &author),
562            Err(DiscoveryRejection::UnsupportedVersion { .. })
563        ));
564    }
565
566    #[test]
567    fn discovery_record_refuses_wrong_length_fields() {
568        let author = author(9);
569        for mutate in [
570            (|r: &mut DiscoveryRecordV1| r.pubkey.truncate(31)) as fn(&mut DiscoveryRecordV1),
571            |r: &mut DiscoveryRecordV1| r.nostr_author.truncate(31),
572            |r: &mut DiscoveryRecordV1| r.sig.truncate(63),
573        ] {
574            let mut record = signed_record(1, "alice", &author);
575            mutate(&mut record);
576            assert!(matches!(
577                verify_discovery_record(&record, &username("alice"), &author),
578                Err(DiscoveryRejection::FieldLength { .. })
579            ));
580        }
581    }
582
583    /// Staleness must not reject. A record signed long ago still verifies; the
584    /// key-package fetch is what decides whether the peer is reachable.
585    #[test]
586    fn discovery_record_accepts_an_old_issue_time() {
587        let author = author(9);
588        let (signing, public, address) = identity(1);
589        let ancient = DiscoveryRecordV1::unsigned(
590            username("alice"),
591            address,
592            public,
593            author.clone(),
594            1_000_000_000_000,
595        )
596        .sign_with(|payload| Ok(signing.sign(payload).to_bytes().to_vec()))
597        .expect("sign");
598
599        assert_eq!(
600            verify_discovery_record(&ancient, &username("alice"), &author),
601            Ok(())
602        );
603    }
604
605    /// A record naming an unnormalized username must not deserialize: the
606    /// repaired form would verify against a tag the record was never published
607    /// at, which is the silent-miss failure the `Username` type exists to stop.
608    #[test]
609    fn discovery_record_refuses_an_unnormalized_username_on_the_wire() {
610        let author = author(9);
611        let record = signed_record(1, "alice", &author);
612        let mut value: serde_json::Value =
613            serde_json::from_slice(&serde_json::to_vec(&record).expect("serialize"))
614                .expect("value");
615        value["username"] = serde_json::Value::String("Alice".to_string());
616        let json = serde_json::to_vec(&value).expect("reserialize");
617        assert!(parse_discovery_body(&json).is_err());
618    }
619
620    /// Golden vector for the signed bytes. A second implementation must produce
621    /// this payload exactly, or the two sign different things and every
622    /// cross-implementation verification fails — silently, since a bad
623    /// signature is indistinguishable from a squatted record.
624    ///
625    /// **Computed independently of this code** by a Python script that builds
626    /// the payload from the written construction and derives the address with
627    /// the BIP-350 reference bech32m. Regenerate it the same way.
628    ///
629    /// The identity is the all-`0x01` Ed25519 seed and the author key is 32
630    /// bytes of `0x09`, so a second implementation can reproduce it without
631    /// this repository.
632    #[test]
633    fn discovery_signing_payload_golden_vector() {
634        let (_, public, address) = identity(1);
635        assert_eq!(
636            address.to_string(),
637            "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
638            "the golden identity's address changed"
639        );
640
641        let payload = DiscoveryRecordV1::signing_payload(
642            &username("alice"),
643            &address,
644            &public,
645            &author(9),
646            1_700_000_000_000,
647        )
648        .expect("payload");
649
650        assert_eq!(
651            base64::engine::general_purpose::STANDARD.encode(&payload),
652            "b2ZmbGluZS1kaXNjLXYxAAAAAQEAAAAFYWxpY2UAAAAsb2ZmMXF5NjgycnVjaDR2bGVseTVka2o5NDI0N2p2YTd6NDl5azV4cHFlZTAAAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAgCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkAAAAIAAABi8/laAA="
653        );
654    }
655}