1
0
Fork 0
mirror of https://codeberg.org/tyy/aspm synced 2024-12-23 00:09:28 -07:00

Fix ASPE logic, add aspe delete, apse create -> aspe upload, couple more tests

This commit is contained in:
Tyler Beckman 2024-07-11 20:04:15 -06:00
parent 61baa8f52f
commit 29c940c623
Signed by: Ty
GPG key ID: 2813440C772555A4
11 changed files with 340 additions and 112 deletions

View file

@ -1,6 +1,7 @@
[workspace] [workspace]
members = ["crates/*"] members = ["crates/*"]
resolver = "2" resolver = "2"
default-members = ["crates/aspm"]
[profile.release] [profile.release]
strip = true strip = true

View file

@ -18,7 +18,7 @@ pub struct AspeVersion {
/// An ASPE-compatible server /// An ASPE-compatible server
pub struct AspeServer { pub struct AspeServer {
host: Host, pub host: Host,
client: reqwest::Client, client: reqwest::Client,
} }
@ -66,6 +66,7 @@ impl From<reqwest::Error> for AspeFetchFailure {
} else { } else {
match value.status().unwrap() { match value.status().unwrap() {
StatusCode::NOT_FOUND => Self::NotFound, StatusCode::NOT_FOUND => Self::NotFound,
StatusCode::BAD_REQUEST => Self::NotFound, // Non-spec compliant: as of now, keyoxide.org will return a 400 when it should return a 404
StatusCode::PAYLOAD_TOO_LARGE => Self::TooLarge, StatusCode::PAYLOAD_TOO_LARGE => Self::TooLarge,
StatusCode::TOO_MANY_REQUESTS => Self::RateLimited, StatusCode::TOO_MANY_REQUESTS => Self::RateLimited,
_ => Self::Unknown(value), _ => Self::Unknown(value),
@ -117,7 +118,7 @@ impl AspeServer {
pub async fn post_request(&self, jws: impl Into<String>) -> Result<(), AspeRequestFailure> { pub async fn post_request(&self, jws: impl Into<String>) -> Result<(), AspeRequestFailure> {
self.client self.client
.post(format!( .post(format!(
"https://{host}/.well-known/aspe/post/", "https://{host}/.well-known/aspe/post",
host = self.host host = self.host
)) ))
.header( .header(
@ -126,7 +127,8 @@ impl AspeServer {
) )
.body(jws.into()) .body(jws.into())
.send() .send()
.await?; .await?
.error_for_status()?;
Ok(()) Ok(())
} }
@ -148,6 +150,7 @@ impl AspeServer {
) )
.send() .send()
.await? .await?
.error_for_status()?
.text_with_charset("utf-8") .text_with_charset("utf-8")
.await?; .await?;

View file

@ -1,3 +1,5 @@
use std::time::SystemTime;
use anyhow::Context; use anyhow::Context;
use josekit::{ use josekit::{
jwk::Jwk, jwk::Jwk,
@ -37,6 +39,8 @@ pub enum JwtSerializationError {
PayloadSerializationError, PayloadSerializationError,
#[error("jwt was unable to be serialized for an unknown reason")] #[error("jwt was unable to be serialized for an unknown reason")]
SerializationError, SerializationError,
#[error("unable to get system time")]
SystemTimeError,
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -69,8 +73,9 @@ impl<O: JwtSerializable + Serialize + for<'de> Deserialize<'de>> JwtSerialize fo
.as_object() .as_object()
.context("serialized struct was not a Map") .context("serialized struct was not a Map")
.or(Err(JwtSerializationError::PayloadSerializationError))?; .or(Err(JwtSerializationError::PayloadSerializationError))?;
let payload = JwtPayload::from_map(map.clone()) let mut payload = JwtPayload::from_map(map.clone())
.or(Err(JwtSerializationError::PayloadSerializationError))?; .or(Err(JwtSerializationError::PayloadSerializationError))?;
payload.set_issued_at(&SystemTime::now());
// Sign it into a JWT // Sign it into a JWT
jwt::encode_with_signer( jwt::encode_with_signer(

View file

@ -0,0 +1,106 @@
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit as _};
use anyhow::{anyhow, bail, Context};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
use asp::{
aspe::{
requests::{AspeRequest, AspeRequestType, AspeRequestVariant},
AspeRequestFailure, AspeServer,
},
keys::AspKey,
url::Host,
utils::jwt::JwtSerialize,
};
use clap::Parser;
use data_encoding::BASE64_NOPAD;
use dialoguer::{theme::ColorfulTheme, Password};
use josekit::jwk::Jwk;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{
commands::AspmSubcommand,
entities::{keys, prelude::*},
};
/// Upload an existing profile to an ASPE server.
#[derive(Parser, Debug)]
pub struct AspeDeleteCommand {
/// The fingerprint or alias of the key to delete
key: String,
/// The ASPE server to upload this profile to
#[clap(value_parser = Host::parse)]
server: Host,
}
#[async_trait::async_trait]
impl AspmSubcommand for AspeDeleteCommand {
async fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> {
let server = AspeServer::new(self.server)
.await
.context("Unable to parse provided server into ASPE server, please verify that it is a valid ASPE server")?;
let Some(key) = Keys::find()
.filter(
Condition::any()
.add(keys::Column::Fingerprint.eq(&self.key))
.add(keys::Column::Alias.eq(&self.key)),
)
.one(&state.db)
.await
.context("Unable to query for keys with fingerprint or alias")?
else {
bail!("No key found with that fingerprint or alias");
};
let key_password = std::env::var("KEY_PASSWORD").or_else(|_| {
Password::with_theme(&ColorfulTheme::default())
.with_prompt("Please enter a password to decrypt the key with")
.interact()
.context("Unable to prompt on stderr")
})?;
let argon_salt =
SaltString::from_b64(&BASE64_NOPAD.encode(key.fingerprint.to_uppercase().as_bytes()))
.context("Unable to decode argon2 salt")?;
let argon2 = Argon2::default();
let hash = argon2
.hash_password(key_password.as_bytes(), &argon_salt)
.or(Err(anyhow!("Unable to derive encryption key")))?;
let aes_key = hash.hash.context("Unable to derive encryption key")?;
let aes_key = &aes_key.as_bytes()[0..32];
let Ok(decrypted) = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(aes_key))
.decrypt((&aes_key[0..12]).into(), key.cipher_text.as_slice())
else {
bail!("Unable to decrypt key from database (wrong password?)")
};
let aspe_request = AspeRequest {
version: 0,
r#type: AspeRequestType::Request,
request: AspeRequestVariant::Delete {
aspe_uri: format!("aspe:{}:{}", server.host, key.fingerprint),
},
};
let encoded_request = aspe_request
.encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?)
.context("Unable to encode the delete request as a JWT and sign it")?;
match server.post_request(encoded_request).await {
Ok(_) => {
println!("Successfully deleted profile from ASPE server!");
Ok(())
}
Err(AspeRequestFailure::BadRequest) => {
bail!("The ASPE server rejected the request due to invalid data")
}
Err(AspeRequestFailure::TooLarge) => {
bail!("The ASPE server rejected the request as being too large")
}
Err(AspeRequestFailure::RateLimited) => {
bail!("The ASPE server rejected the request due to a ratelimit")
}
Err(AspeRequestFailure::Unknown(e)) => Err(e.into()),
}
}
}

View file

@ -1,8 +1,9 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
mod create; mod upload;
mod delete;
/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles. /// A subcommand to allow management of profiles on ASPE servers.
#[derive(Parser)] #[derive(Parser)]
pub struct AspeSubcommand { pub struct AspeSubcommand {
#[command(subcommand)] #[command(subcommand)]
@ -11,5 +12,6 @@ pub struct AspeSubcommand {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum AspeSubcommands { pub enum AspeSubcommands {
Create(create::AspeCreateCommand), Upload(upload::AspeUploadCommand),
Delete(delete::AspeDeleteCommand),
} }

View file

@ -4,7 +4,7 @@ use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
use asp::{ use asp::{
aspe::{ aspe::{
requests::{AspeRequest, AspeRequestType, AspeRequestVariant}, requests::{AspeRequest, AspeRequestType, AspeRequestVariant},
AspeRequestFailure, AspeServer, AspeFetchFailure, AspeRequestFailure, AspeServer,
}, },
hex_color::HexColor, hex_color::HexColor,
keys::AspKey, keys::AspKey,
@ -24,9 +24,9 @@ use crate::{
entities::{prelude::*, profiles}, entities::{prelude::*, profiles},
}; };
/// Upload an existing profile to an ASPE server /// Upload an existing profile to an ASPE server.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub struct AspeCreateCommand { pub struct AspeUploadCommand {
/// The fingerprint or alias of the profile to upload /// The fingerprint or alias of the profile to upload
profile: String, profile: String,
/// The ASPE server to upload this profile to /// The ASPE server to upload this profile to
@ -35,10 +35,13 @@ pub struct AspeCreateCommand {
/// The key to sign this profile with /// The key to sign this profile with
#[clap(short, long)] #[clap(short, long)]
key: Option<String>, key: Option<String>,
/// If passed, this command will only create new keys on the server, rather than updating if the key already exists
#[clap(long)]
no_update: bool,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl AspmSubcommand for AspeCreateCommand { impl AspmSubcommand for AspeUploadCommand {
async fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> { async fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> {
let server = AspeServer::new(self.server) let server = AspeServer::new(self.server)
.await .await
@ -63,7 +66,8 @@ impl AspmSubcommand for AspeCreateCommand {
.await .await
.context("Unable to query for related claims")?; .context("Unable to query for related claims")?;
let Some(key) = Keys::find_by_id(self.key.unwrap_or(profile.key)) let fingerprint = self.key.unwrap_or(profile.key);
let Some(key) = Keys::find_by_id(&fingerprint)
.one(&state.db) .one(&state.db)
.await .await
.context("Unable to query database for key")? .context("Unable to query database for key")?
@ -118,17 +122,37 @@ impl AspmSubcommand for AspeCreateCommand {
.encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?) .encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?)
.context("Unable to encode the profile as a JWT and sign it")?; .context("Unable to encode the profile as a JWT and sign it")?;
let create_request = AspeRequest { // Check if the key is already used on the server, to send an update request instead of a create request
let should_update_profile = match server.fetch_profile(&fingerprint).await {
Ok(_) if self.no_update => bail!("A profile with the selected key was already present on the selected server, but the --no-update flag was passed"),
Ok(_) => true,
Err(AspeFetchFailure::NotFound) => false,
Err(AspeFetchFailure::TooLarge) => bail!("ASPE server rejected the request to fetch existing profile as it was too large"),
Err(AspeFetchFailure::RateLimited) => bail!("ASPE server returned a ratelimit error while fetching existing profile, try again later"),
Err(AspeFetchFailure::Unknown(e)) => bail!(e),
};
let aspe_request = match should_update_profile {
false => AspeRequest {
version: 0, version: 0,
r#type: AspeRequestType::Request, r#type: AspeRequestType::Request,
request: AspeRequestVariant::Create { request: AspeRequestVariant::Create {
profile_jws: encoded_profile, profile_jws: encoded_profile,
}, },
},
true => AspeRequest {
version: 0,
r#type: AspeRequestType::Request,
request: AspeRequestVariant::Update {
profile_jws: encoded_profile,
aspe_uri: format!("aspe:{}:{}", server.host, fingerprint),
},
},
}; };
let encoded_request = create_request let encoded_request = aspe_request
.encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?) .encode_and_sign(&AspKey::from_jwk(Jwk::from_bytes(&decrypted)?)?)
.context("Unable to encode the profile as a JWT and sign it")?; .context("Unable to encode the profile request as a JWT and sign it")?;
match server.post_request(encoded_request).await { match server.post_request(encoded_request).await {
Ok(_) => { Ok(_) => {

View file

@ -22,6 +22,8 @@ use crate::{
pub enum KeyImportFormat { pub enum KeyImportFormat {
// An unencrypted raw JSON Web Key // An unencrypted raw JSON Web Key
Jwk, Jwk,
// An unencrypted raw PKCS#8 key
Pkcs8,
// Imports a PGP key from a local GPG store // Imports a PGP key from a local GPG store
#[cfg(feature = "gpg-compat")] #[cfg(feature = "gpg-compat")]
Gpg, Gpg,
@ -71,6 +73,9 @@ impl AspmSubcommand for KeysImportCommand {
Jwk::from_bytes(self.key.as_bytes()).context("Unable to parse provided JWK")?, Jwk::from_bytes(self.key.as_bytes()).context("Unable to parse provided JWK")?,
) )
.context("Unable to convert parsed JWK to an AspKey")?, .context("Unable to convert parsed JWK to an AspKey")?,
KeyImportFormat::Pkcs8 => {
AspKey::from_pkcs8(&self.key).context("Unable to convert PKCS#8 to an AspKey")?
}
#[cfg(feature = "gpg-compat")] #[cfg(feature = "gpg-compat")]
KeyImportFormat::Gpg => { KeyImportFormat::Gpg => {
use gpgme::{Context as GpgContext, PassphraseRequest}; use gpgme::{Context as GpgContext, PassphraseRequest};

View file

@ -7,7 +7,7 @@ pub mod export;
pub mod import; pub mod import;
pub mod list; pub mod list;
/// A subcommand to allow the management of keys, which can then be used to create, modify, or delete profiles. /// A subcommand to allow the management of profiles.
#[derive(Parser)] #[derive(Parser)]
pub struct ProfilesSubcommand { pub struct ProfilesSubcommand {
#[command(subcommand)] #[command(subcommand)]

View file

@ -174,7 +174,8 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
ProfilesSubcommands::Import(subcommand) => subcommand.execute_sync(state, runtime), ProfilesSubcommands::Import(subcommand) => subcommand.execute_sync(state, runtime),
}, },
AspmSubcommands::Aspe(subcommand) => match subcommand.subcommand { AspmSubcommands::Aspe(subcommand) => match subcommand.subcommand {
AspeSubcommands::Create(subcommand) => subcommand.execute_sync(state, runtime), AspeSubcommands::Upload(subcommand) => subcommand.execute_sync(state, runtime),
AspeSubcommands::Delete(subcommand) => subcommand.execute_sync(state, runtime),
}, },
} }
} }

View file

@ -1,8 +1,11 @@
mod consts;
use anyhow::Context; use anyhow::Context;
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use consts::*;
use predicates::prelude::*; use predicates::prelude::*;
use tempfile::TempDir;
use std::process::Command; use std::process::Command;
use tempfile::TempDir;
static KEY_ALIAS: &str = "TESTKEY"; static KEY_ALIAS: &str = "TESTKEY";
static KEY_PASSWORD: &str = "TESTKEYPASSWORD"; static KEY_PASSWORD: &str = "TESTKEYPASSWORD";
@ -19,7 +22,9 @@ fn assert_key_generated(datadir: &str) -> Result<(), anyhow::Error> {
.arg(KEY_ALIAS) .arg(KEY_ALIAS)
.assert() .assert()
.success() .success()
.stdout(predicate::str::starts_with("Successfully generated a new key!")); .stdout(predicate::str::starts_with(
"Successfully generated a new key!",
));
Ok(()) Ok(())
} }
@ -27,7 +32,10 @@ fn assert_key_generated(datadir: &str) -> Result<(), anyhow::Error> {
#[test] #[test]
fn help_works() -> Result<(), anyhow::Error> { fn help_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?; let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
Command::cargo_bin("aspm")? Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir) .env("ASPM_DATA_DIR", datadir)
@ -62,7 +70,10 @@ fn help_works() -> Result<(), anyhow::Error> {
#[test] #[test]
fn keys_generate_works() -> Result<(), anyhow::Error> { fn keys_generate_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?; let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir) assert_key_generated(datadir)
} }
@ -70,7 +81,10 @@ fn keys_generate_works() -> Result<(), anyhow::Error> {
#[test] #[test]
fn keys_list_works() -> Result<(), anyhow::Error> { fn keys_list_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?; let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?; assert_key_generated(datadir)?;
@ -89,7 +103,10 @@ fn keys_list_works() -> Result<(), anyhow::Error> {
#[test] #[test]
fn keys_export_works() -> Result<(), anyhow::Error> { fn keys_export_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?; let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?; assert_key_generated(datadir)?;
@ -109,10 +126,66 @@ fn keys_export_works() -> Result<(), anyhow::Error> {
Ok(()) Ok(())
} }
#[test]
fn keys_import_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
for (export_format, key) in [
("pkcs8", TEST_KEY_PKCS8),
("asp-tool", TEST_KEY_ASPTOOL),
("jwk", TEST_KEY_JWK),
] {
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("import")
.env("KEY_PASSWORD", TEST_KEY_PASSWORD)
.arg("--key-alias")
.arg(KEY_ALIAS)
.arg(export_format)
.arg(key)
.assert()
.success()
.stdout(
predicate::str::contains("Successfully imported key!")
.and(predicate::str::contains(TEST_FINGERPRINT)),
);
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains(TEST_FINGERPRINT));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("delete")
.arg("--no-confirm")
.arg(TEST_FINGERPRINT)
.assert()
.success()
.stdout(predicate::str::contains(format!(
"Successfully deleted key with fingerprint {TEST_FINGERPRINT}"
)));
}
Ok(())
}
#[test] #[test]
fn keys_delete_works() -> Result<(), anyhow::Error> { fn keys_delete_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?; let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?; let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?; assert_key_generated(datadir)?;
@ -124,7 +197,9 @@ fn keys_delete_works() -> Result<(), anyhow::Error> {
.arg(KEY_ALIAS) .arg(KEY_ALIAS)
.assert() .assert()
.success() .success()
.stdout(predicate::str::contains("Successfully deleted key with fingerprint ")); .stdout(predicate::str::contains(
"Successfully deleted key with fingerprint ",
));
Command::cargo_bin("aspm")? Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir) .env("ASPM_DATA_DIR", datadir)

View file

@ -0,0 +1,6 @@
pub const TEST_FINGERPRINT: &str = "XOWIFY4ZKMWYIXXVVAEHC437HE";
pub const TEST_KEY_PASSWORD: &str = "test";
pub const TEST_KEY_PKCS8: &str = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3B9nMzvh+bGKn3zUsF5M+0VJJXAOShG2HJDC/uez0RehRANCAARzaHaGFWH/DXwbzDAMDmU7eCyb79wUcytBut7oKh2keujlkBSDBOd6gCRuqwZDPoNMYDAeFCKAoll10K6Oeg+D";
pub const TEST_KEY_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"3B9nMzvh-bGKn3zUsF5M-0VJJXAOShG2HJDC_uez0Rc","x":"c2h2hhVh_w18G8wwDA5lO3gsm-_cFHMrQbre6CodpHo","y":"6OWQFIME53qAJG6rBkM-g0xgMB4UIoCiWXXQro56D4M"}"#;
pub const TEST_KEY_ASPTOOL: &str = "eyJhbGciOiJzY3J5cHQiLCJwcm0iOnsiTiI6MTMxMDcyLCJyIjo4LCJwIjoxfSwic2x0IjoiUUJSQXZ5Q1JtNGVkMnBVb2R6RktpUT09Iiwia2V5IjoiYVdCWWdMTGR4T2tBb1JWYWI4T1EzcG15UXpDR1V3VXVBVDJ2WDhTZUwrTTJxY2tVOFNVR3FKTWJpVkh4cnZWQjNZNXVTbGZQc3F0RGc3M0NIZmROZ2U2Q0NQN1hVZTVuek1MUUVERVZOU3IwTDAxeG92TDc5WE4rZVV1V3ZwU1BjaEU5Ukt2SG5YQ25sWXZSTjZIdlVKVURuVGV1V3pKM0RScVY3RmxST2RjSHplMW5wYlhjd0pDWiJ9";
pub const TEST_PROFILE: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IlhPV0lGWTRaS01XWUlYWFZWQUVIQzQzN0hFIiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiYzJoMmhoVmhfdzE4Rzh3d0RBNWxPM2dzbS1fY0ZITXJRYnJlNkNvZHBIbyIsInkiOiI2T1dRRklNRTUzcUFKRzZyQmtNLWcweGdNQjRVSW9DaVdYWFFybzU2RDRNIn19.eyJodHRwOi8vYXJpYWRuZS5pZC92ZXJzaW9uIjowLCJodHRwOi8vYXJpYWRuZS5pZC90eXBlIjoicHJvZmlsZSIsImh0dHA6Ly9hcmlhZG5lLmlkL25hbWUiOiJUeSB0ZXN0aW5nIiwiaHR0cDovL2FyaWFkbmUuaWQvY2xhaW1zIjpbImh0dHBzOi8vZ29vZ2xlLmNvbSJdLCJodHRwOi8vYXJpYWRuZS5pZC9kZXNjcmlwdGlvbiI6IlRoaXMgaXMgcHVyZWx5IGZvciB0ZXN0aW5nIEFTUE0iLCJodHRwOi8vYXJpYWRuZS5pZC9lbWFpbCI6ImV4YW1wbGVAZXhhbXBsZS5jb20iLCJodHRwOi8vYXJpYWRuZS5pZC9jb2xvciI6IiM3OTI5MUEifQ.EZv1ZKqU6xAtzaOv_EhE_6uUZDd6Mnk7jNQcsc8CUcw8mKNnQQ-yEC-MZpAiL9KL-ljZTKEW7gNTB7GnI6uVJQ";