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}