1
0
Fork 0
mirror of https://codeberg.org/tyy/aspm synced 2024-12-22 19:29: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]
members = ["crates/*"]
resolver = "2"
default-members = ["crates/aspm"]
[profile.release]
strip = true

View file

@ -18,7 +18,7 @@ pub struct AspeVersion {
/// An ASPE-compatible server
pub struct AspeServer {
host: Host,
pub host: Host,
client: reqwest::Client,
}
@ -66,6 +66,7 @@ impl From<reqwest::Error> for AspeFetchFailure {
} else {
match value.status().unwrap() {
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::TOO_MANY_REQUESTS => Self::RateLimited,
_ => Self::Unknown(value),
@ -117,7 +118,7 @@ impl AspeServer {
pub async fn post_request(&self, jws: impl Into<String>) -> Result<(), AspeRequestFailure> {
self.client
.post(format!(
"https://{host}/.well-known/aspe/post/",
"https://{host}/.well-known/aspe/post",
host = self.host
))
.header(
@ -126,7 +127,8 @@ impl AspeServer {
)
.body(jws.into())
.send()
.await?;
.await?
.error_for_status()?;
Ok(())
}
@ -148,6 +150,7 @@ impl AspeServer {
)
.send()
.await?
.error_for_status()?
.text_with_charset("utf-8")
.await?;

View file

@ -1,3 +1,5 @@
use std::time::SystemTime;
use anyhow::Context;
use josekit::{
jwk::Jwk,
@ -37,6 +39,8 @@ pub enum JwtSerializationError {
PayloadSerializationError,
#[error("jwt was unable to be serialized for an unknown reason")]
SerializationError,
#[error("unable to get system time")]
SystemTimeError,
}
#[derive(Error, Debug)]
@ -69,8 +73,9 @@ impl<O: JwtSerializable + Serialize + for<'de> Deserialize<'de>> JwtSerialize fo
.as_object()
.context("serialized struct was not a Map")
.or(Err(JwtSerializationError::PayloadSerializationError))?;
let payload = JwtPayload::from_map(map.clone())
let mut payload = JwtPayload::from_map(map.clone())
.or(Err(JwtSerializationError::PayloadSerializationError))?;
payload.set_issued_at(&SystemTime::now());
// Sign it into a JWT
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};
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)]
pub struct AspeSubcommand {
#[command(subcommand)]
@ -11,5 +12,6 @@ pub struct AspeSubcommand {
#[derive(Subcommand)]
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::{
aspe::{
requests::{AspeRequest, AspeRequestType, AspeRequestVariant},
AspeRequestFailure, AspeServer,
AspeFetchFailure, AspeRequestFailure, AspeServer,
},
hex_color::HexColor,
keys::AspKey,
@ -24,9 +24,9 @@ use crate::{
entities::{prelude::*, profiles},
};
/// Upload an existing profile to an ASPE server
/// Upload an existing profile to an ASPE server.
#[derive(Parser, Debug)]
pub struct AspeCreateCommand {
pub struct AspeUploadCommand {
/// The fingerprint or alias of the profile to upload
profile: String,
/// The ASPE server to upload this profile to
@ -35,10 +35,13 @@ pub struct AspeCreateCommand {
/// The key to sign this profile with
#[clap(short, long)]
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]
impl AspmSubcommand for AspeCreateCommand {
impl AspmSubcommand for AspeUploadCommand {
async fn execute(self, state: crate::AspmState) -> Result<(), anyhow::Error> {
let server = AspeServer::new(self.server)
.await
@ -63,7 +66,8 @@ impl AspmSubcommand for AspeCreateCommand {
.await
.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)
.await
.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)?)?)
.context("Unable to encode the profile as a JWT and sign it")?;
let create_request = AspeRequest {
version: 0,
r#type: AspeRequestType::Request,
request: AspeRequestVariant::Create {
profile_jws: encoded_profile,
// 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,
r#type: AspeRequestType::Request,
request: AspeRequestVariant::Create {
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)?)?)
.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 {
Ok(_) => {

View file

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

View file

@ -7,7 +7,7 @@ pub mod export;
pub mod import;
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)]
pub struct ProfilesSubcommand {
#[command(subcommand)]

View file

@ -174,8 +174,9 @@ fn cli(parsed: AspmCommand) -> Result<(), anyhow::Error> {
ProfilesSubcommands::Import(subcommand) => subcommand.execute_sync(state, runtime),
},
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,138 +1,213 @@
mod consts;
use anyhow::Context;
use assert_cmd::prelude::*;
use consts::*;
use predicates::prelude::*;
use tempfile::TempDir;
use std::process::Command;
use tempfile::TempDir;
static KEY_ALIAS: &str = "TESTKEY";
static KEY_PASSWORD: &str = "TESTKEYPASSWORD";
// TODO: Also test ed25519 key generation
fn assert_key_generated(datadir: &str) -> Result<(), anyhow::Error> {
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("generate")
.env("KEY_PASSWORD", KEY_PASSWORD)
.arg("es256")
.arg("--key-alias")
.arg(KEY_ALIAS)
.assert()
.success()
.stdout(predicate::str::starts_with("Successfully generated a new key!"));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("generate")
.env("KEY_PASSWORD", KEY_PASSWORD)
.arg("es256")
.arg("--key-alias")
.arg(KEY_ALIAS)
.assert()
.success()
.stdout(predicate::str::starts_with(
"Successfully generated a new key!",
));
Ok(())
Ok(())
}
#[test]
fn help_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?;
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("--help")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("--help")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("-h")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("-h")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("help")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("help")
.assert()
.success()
.stdout(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.assert()
.code(2)
.stderr(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.assert()
.code(2)
.stderr(predicate::str::starts_with(env!("CARGO_PKG_DESCRIPTION")));
Ok(())
Ok(())
}
#[test]
fn keys_generate_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?;
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)
assert_key_generated(datadir)
}
#[test]
fn keys_list_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?;
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?;
assert_key_generated(datadir)?;
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains(KEY_ALIAS));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains(KEY_ALIAS));
Ok(())
Ok(())
}
// This test takes a bit due to testing each export format individually, causing the password to be hashed multiple times
#[test]
fn keys_export_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?;
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?;
assert_key_generated(datadir)?;
for export_format in ["pkcs8", "asp-tool", "jwk"] {
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("export")
.env("KEY_PASSWORD", KEY_PASSWORD)
.arg(export_format)
.arg(KEY_ALIAS)
.assert()
.success()
.stderr(predicate::str::contains("Exported key \""));
}
for export_format in ["pkcs8", "asp-tool", "jwk"] {
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("export")
.env("KEY_PASSWORD", KEY_PASSWORD)
.arg(export_format)
.arg(KEY_ALIAS)
.assert()
.success()
.stderr(predicate::str::contains("Exported key \""));
}
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]
fn keys_delete_works() -> Result<(), anyhow::Error> {
let tempdir = TempDir::new()?;
let datadir = tempdir.path().to_str().context("Tempdir path was not valid utf8")?;
let tempdir = TempDir::new()?;
let datadir = tempdir
.path()
.to_str()
.context("Tempdir path was not valid utf8")?;
assert_key_generated(datadir)?;
assert_key_generated(datadir)?;
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("delete")
.arg("--no-confirm")
.arg(KEY_ALIAS)
.assert()
.success()
.stdout(predicate::str::contains("Successfully deleted key with fingerprint "));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("delete")
.arg("--no-confirm")
.arg(KEY_ALIAS)
.assert()
.success()
.stdout(predicate::str::contains(
"Successfully deleted key with fingerprint ",
));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Saved keys (0 total):"));
Command::cargo_bin("aspm")?
.env("ASPM_DATA_DIR", datadir)
.arg("keys")
.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Saved keys (0 total):"));
Ok(())
}
Ok(())
}

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";