diff --git a/Cargo.toml b/Cargo.toml index f26d23c..ccb259b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = ["crates/*"] resolver = "2" +default-members = ["crates/aspm"] [profile.release] strip = true diff --git a/crates/asp/src/aspe/mod.rs b/crates/asp/src/aspe/mod.rs index e3273e6..4868999 100644 --- a/crates/asp/src/aspe/mod.rs +++ b/crates/asp/src/aspe/mod.rs @@ -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 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) -> 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?; diff --git a/crates/asp/src/utils/jwt.rs b/crates/asp/src/utils/jwt.rs index 7f25fe4..3ff8614 100644 --- a/crates/asp/src/utils/jwt.rs +++ b/crates/asp/src/utils/jwt.rs @@ -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 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( diff --git a/crates/aspm/src/commands/aspe/delete.rs b/crates/aspm/src/commands/aspe/delete.rs new file mode 100644 index 0000000..4dd4ae9 --- /dev/null +++ b/crates/aspm/src/commands/aspe/delete.rs @@ -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::::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()), + } + } +} diff --git a/crates/aspm/src/commands/aspe/mod.rs b/crates/aspm/src/commands/aspe/mod.rs index f29ba09..171ef23 100644 --- a/crates/aspm/src/commands/aspe/mod.rs +++ b/crates/aspm/src/commands/aspe/mod.rs @@ -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), } diff --git a/crates/aspm/src/commands/aspe/create.rs b/crates/aspm/src/commands/aspe/upload.rs similarity index 71% rename from crates/aspm/src/commands/aspe/create.rs rename to crates/aspm/src/commands/aspe/upload.rs index 32ccbc0..ff75cf7 100644 --- a/crates/aspm/src/commands/aspe/create.rs +++ b/crates/aspm/src/commands/aspe/upload.rs @@ -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, + /// 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(_) => { diff --git a/crates/aspm/src/commands/keys/import.rs b/crates/aspm/src/commands/keys/import.rs index 46dc70a..391873d 100644 --- a/crates/aspm/src/commands/keys/import.rs +++ b/crates/aspm/src/commands/keys/import.rs @@ -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}; diff --git a/crates/aspm/src/commands/profiles/mod.rs b/crates/aspm/src/commands/profiles/mod.rs index 5ece472..d73885f 100644 --- a/crates/aspm/src/commands/profiles/mod.rs +++ b/crates/aspm/src/commands/profiles/mod.rs @@ -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)] diff --git a/crates/aspm/src/main.rs b/crates/aspm/src/main.rs index 6598ed5..fb0a3ec 100644 --- a/crates/aspm/src/main.rs +++ b/crates/aspm/src/main.rs @@ -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), + }, } } diff --git a/crates/aspm/tests/cli.rs b/crates/aspm/tests/cli.rs index 21237b1..ea66443 100644 --- a/crates/aspm/tests/cli.rs +++ b/crates/aspm/tests/cli.rs @@ -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(()) -} \ No newline at end of file + Ok(()) +} diff --git a/crates/aspm/tests/consts.rs b/crates/aspm/tests/consts.rs new file mode 100644 index 0000000..38feb52 --- /dev/null +++ b/crates/aspm/tests/consts.rs @@ -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"; \ No newline at end of file