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}