test(connect): isolate offline enrollment e2e root (#6659)

* test(connect): isolate offline enrollment e2e root

* test(connect): own e2e issuer key id
This commit is contained in:
Zhengchao An
2026-08-26 18:50:58 +08:00
committed by GitHub
parent 9ea5cd59ca
commit 6ef7bac071
11 changed files with 284 additions and 9 deletions
+7
View File
@@ -39,6 +39,11 @@ path = "src/main.rs"
name = "rustfs-cli"
path = "src/bin/rustfs-cli.rs"
[[bin]]
name = "rustfs-cli-e2e"
path = "src/bin/rustfs-cli.rs"
required-features = ["offline-enrollment-e2e-root"]
[features]
default = ["ftps", "webdav"]
metrics-gpu = ["rustfs-obs/gpu"]
@@ -52,6 +57,8 @@ tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise,
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope"]
manual-test-runners = []
e2e-test-hooks = []
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
offline-enrollment-e2e-root = []
rio-v2 = ["rustfs-ecstore/rio-v2"]
pyroscope = ["rustfs-obs/pyroscope"]
# Tokio runtime telemetry. Requires `--cfg tokio_unstable`; use `make build-profiling`.
+51
View File
@@ -294,7 +294,12 @@ fn enroll(arguments: &[String]) -> Result<(), String> {
.duration_since(UNIX_EPOCH)
.map_err(|_| "the system clock is before the Unix epoch".to_string())?
.as_secs() as i64;
#[cfg(feature = "offline-enrollment-e2e-root")]
let now = enrollment_evaluation_time(now)?;
#[cfg(feature = "offline-enrollment-e2e-root")]
let verified = verify_enrollment_challenge(&challenge, now).map_err(|error| error.to_string())?;
#[cfg(not(feature = "offline-enrollment-e2e-root"))]
let verified = OfflineEnrollment::verify_challenge(&challenge, now).map_err(|error| error.to_string())?;
// First use mints the key; a retry answers with the one already enrolled,
@@ -313,6 +318,32 @@ fn enroll(arguments: &[String]) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "offline-enrollment-e2e-root")]
fn enrollment_evaluation_time(system_now: i64) -> Result<i64, String> {
// The public fixture gate needs a compile-time clock; real E2E builds omit it.
if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e"
&& let Some(value) = option_env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_FIXTURE_TIME")
{
return value
.parse()
.map_err(|_| "the compiled E2E enrollment fixture time is invalid".to_owned());
}
Ok(system_now)
}
#[cfg(feature = "offline-enrollment-e2e-root")]
fn verify_enrollment_challenge(
challenge: &[u8],
now: i64,
) -> Result<rustfs::connect::offline::VerifiedChallenge, rustfs::connect::offline::EnrollmentError> {
if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e" {
return OfflineEnrollment::verify_e2e_challenge(challenge, now);
}
OfflineEnrollment::verify_challenge(challenge, now)
}
/// Reads the challenge from a file, or from stdin when the path is `-`.
///
/// A challenge is not a secret — it is signed, public, and carried in by hand —
@@ -420,6 +451,26 @@ mod tests {
use super::*;
#[cfg(feature = "offline-enrollment-e2e-root")]
#[test]
fn only_the_dedicated_e2e_target_selects_the_test_root() {
let challenge =
fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/offline-enrollment-e2e/challenge.json"))
.expect("read E2E enrollment challenge");
let result = verify_enrollment_challenge(&challenge, 4_070_908_800);
if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e" {
result.expect("the dedicated E2E binary selects the test root");
} else {
assert_eq!(
result
.expect_err("every production binary must retain the hosted root")
.reason(),
"ENROLLMENT_ROOT_UNKNOWN"
);
}
}
#[test]
fn connect_offline_bundle_cli_shutdown_is_bounded_with_a_stuck_blocking_collector() {
let runtime = tokio::runtime::Builder::new_current_thread().build().expect("test runtime");
+39 -8
View File
@@ -46,8 +46,22 @@ use crate::connect::identity::DeviceIdentity;
/// The hosted enrolment root, compiled in. Both halves are pinned: the
/// fingerprint identifies the root, and the point is what actually verifies the
/// first link, so a build cannot be pointed at a different key by supplying one.
const PINNED_ROOT_KEY_ID: &str = "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f";
const PINNED_ROOT_PUBLIC_KEY: &str = "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s";
const HOSTED_ROOT: EnrollmentRoot = EnrollmentRoot {
key_id: "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f",
public_key: "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s",
};
#[cfg(feature = "offline-enrollment-e2e-root")]
const E2E_ROOT: EnrollmentRoot = EnrollmentRoot {
key_id: env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_KEY_ID"),
public_key: env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_PUBLIC_KEY"),
};
#[derive(Clone, Copy)]
struct EnrollmentRoot {
key_id: &'static str,
public_key: &'static str,
}
/// Domain separation tags. A document that verifies under one of these must not
/// be accepted for another artifact type, so the tag is part of the signature
@@ -327,6 +341,22 @@ impl OfflineEnrollment {
/// `now_unix` is the device's reading of the current time, which the clock
/// skew tolerance treats as advisory.
pub fn verify_challenge(document: &[u8], now_unix: i64) -> Result<VerifiedChallenge, EnrollmentError> {
Self::verify_challenge_with_root(document, now_unix, HOSTED_ROOT)
}
/// Verify a challenge against the fixed root carried only by the dedicated
/// E2E CLI build. No caller can supply or replace this root at runtime.
#[cfg(feature = "offline-enrollment-e2e-root")]
#[doc(hidden)]
pub fn verify_e2e_challenge(document: &[u8], now_unix: i64) -> Result<VerifiedChallenge, EnrollmentError> {
Self::verify_challenge_with_root(document, now_unix, E2E_ROOT)
}
fn verify_challenge_with_root(
document: &[u8],
now_unix: i64,
root: EnrollmentRoot,
) -> Result<VerifiedChallenge, EnrollmentError> {
let envelope: SignedDocument = serde_json::from_slice(document).map_err(|_| EnrollmentError::MalformedDocument)?;
// Step 1: the encoding is checked before anything is decoded from it, so
@@ -345,7 +375,7 @@ impl OfflineEnrollment {
let issued_at = parse_timestamp(&routing.issued_at)?;
// Steps 3 to 5.
let connect_key = verify_trust_chain(&routing.trust_chain, &routing.connect_key_id, issued_at)?;
let connect_key = verify_trust_chain(&routing.trust_chain, &routing.connect_key_id, issued_at, root)?;
// Step 6. The verification key comes from the chain, so `signature.keyId`
// is a label rather than an input: a value naming some other key simply
@@ -439,24 +469,25 @@ fn verify_trust_chain(
chain: &[SignedDocument],
connect_key_id: &str,
challenge_issued_at: i64,
root: EnrollmentRoot,
) -> Result<VerifyingKey, EnrollmentError> {
// The pinned root gate runs before the chain's shape is examined, so a
// chain that is internally consistent under a foreign root — exactly what
// trust on first use would have accepted — is refused for its root rather
// than for its length.
let first = chain.first().ok_or(EnrollmentError::EnrollmentRootUnknown)?;
let root = decode_trust_link(first)?;
if root.0.issuer_key_id != PINNED_ROOT_KEY_ID {
let first_link = decode_trust_link(first)?;
if first_link.0.issuer_key_id != root.key_id {
return Err(EnrollmentError::EnrollmentRootUnknown);
}
let [_, second] = chain else {
return Err(EnrollmentError::TrustChainInvalid);
};
let links = [root, decode_trust_link(second)?];
let links = [first_link, decode_trust_link(second)?];
let mut issuer_key_id = PINNED_ROOT_KEY_ID.to_owned();
let (mut issuer_key, _) = decode_public_key(PINNED_ROOT_PUBLIC_KEY).ok_or(EnrollmentError::EnrollmentRootUnknown)?;
let mut issuer_key_id = root.key_id.to_owned();
let (mut issuer_key, _) = decode_public_key(root.public_key).ok_or(EnrollmentError::EnrollmentRootUnknown)?;
for (index, ((link, link_bytes), entry)) in links.iter().zip(chain).enumerate() {
if link.format_version != FORMAT_TRUST_LINK
@@ -54,6 +54,14 @@ fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/offline-enrollment")
}
#[cfg(feature = "offline-enrollment-e2e-root")]
fn e2e_fixture(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/offline-enrollment-e2e")
.join(name);
fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
}
@@ -268,6 +276,85 @@ fn every_challenge_accept_vector_verifies_and_exposes_the_signed_fields() {
);
}
#[cfg(feature = "offline-enrollment-e2e-root")]
#[test]
fn e2e_root_is_fixed_and_disjoint_from_the_hosted_root() {
let challenge = e2e_fixture("challenge.json");
let now = unix("2099-01-01T00:00:00Z");
let verified =
OfflineEnrollment::verify_e2e_challenge(&challenge, now).expect("the dedicated E2E root verifies its challenge");
assert_eq!(verified.challenge_id, "018f7e6d-9d6a-7d93-8f64-8b20b3384712");
assert_eq!(
OfflineEnrollment::verify_challenge(&challenge, now)
.expect_err("the production verifier must not trust the E2E root")
.reason(),
"ENROLLMENT_ROOT_UNKNOWN"
);
let hosted = accept_vector_named("challenge signed by a chained signing key under the pinned root");
let hosted_now = unix(field(&hosted, "evaluationTime"));
assert_eq!(
OfflineEnrollment::verify_e2e_challenge(&envelope(&hosted["document"]), hosted_now)
.expect_err("the E2E verifier must not silently retain the hosted root")
.reason(),
"ENROLLMENT_ROOT_UNKNOWN"
);
}
#[cfg(feature = "offline-enrollment-e2e-root")]
#[test]
fn e2e_public_chain_matches_the_challenge_and_every_signature_verifies() {
use p256::ecdsa::signature::Verifier as _;
let root: Value = serde_json::from_slice(&e2e_fixture("root.json")).expect("E2E root parses");
let chain_bytes = e2e_fixture("trust-chain.json");
let chain: Value = serde_json::from_slice(&chain_bytes).expect("E2E chain parses");
let challenge_envelope: Value = serde_json::from_slice(&e2e_fixture("challenge.json")).expect("E2E challenge parses");
let challenge_bytes = signed_octets(&challenge_envelope);
let challenge: Value = serde_json::from_slice(&challenge_bytes).expect("signed E2E challenge parses");
assert_eq!(challenge["trustChain"], chain, "the independent and embedded chain JSON must match");
let compact_chain = chain_bytes
.strip_suffix(b"\n")
.expect("the chain fixture has one final newline");
assert!(
challenge_bytes
.windows(compact_chain.len())
.any(|window| window == compact_chain),
"the signed challenge must embed the independent chain byte for byte"
);
let mut issuer = verifying_key(field(&root, "publicKey"));
let mut issuer_id = field(&root, "keyId").to_owned();
for link in chain.as_array().expect("E2E chain is a list") {
assert_eq!(field(&link["signature"], "keyId"), issuer_id.as_str());
let signature = BASE64_URL_NO_PAD
.decode(field(&link["signature"], "value"))
.expect("trust-link signature is base64url");
issuer
.verify(
&signing_input("rustfs-offline-trust-link-v1", &signed_octets(link)),
&p256::ecdsa::Signature::from_slice(&signature).expect("trust-link signature parses"),
)
.expect("trust-link signature verifies");
let document = signed_document(link);
issuer_id = field(&document, "subjectKeyId").to_owned();
issuer = verifying_key(field(&document, "subjectPublicKey"));
}
assert_eq!(field(&challenge, "connectKeyId"), issuer_id.as_str());
let signature = BASE64_URL_NO_PAD
.decode(field(&challenge_envelope["signature"], "value"))
.expect("challenge signature is base64url");
issuer
.verify(
&signing_input("rustfs-offline-enrollment-challenge-v1", &challenge_bytes),
&p256::ecdsa::Signature::from_slice(&signature).expect("challenge signature parses"),
)
.expect("challenge signature verifies");
}
/// Connect's own producer wrote the response accept vectors. Rebuilding them
/// from the challenge they answer, with the device nonce and production time
/// they used, must reproduce every field that does not depend on which device
@@ -0,0 +1,8 @@
{
"bytes": "eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLCJwcm90b2NvbFZlcnNpb24iOiJ2MSIsImNoYWxsZW5nZUlkIjoiMDE4ZjdlNmQtOWQ2YS03ZDkzLThmNjQtOGIyMGIzMzg0NzEyIiwib3JnYW5pemF0aW9uTmFtZSI6Im9yZ2FuaXphdGlvbnMvMDE4ZjdlNmQtOWQ2YS03ZDkzLThmNjQtOGIyMGIzMzg0NzEzIiwiY2x1c3Rlck5hbWUiOiJvcmdhbml6YXRpb25zLzAxOGY3ZTZkLTlkNmEtN2Q5My04ZjY0LThiMjBiMzM4NDcxMy9jbHVzdGVycy8wMThmN2U2ZC05ZDZhLTdkOTMtOGY2NC04YjIwYjMzODQ3MTQiLCJub25jZSI6InBLU2twS1NrcEtTa3BLU2twS1NrcEtTa3BLU2twS1NrcEtTa3BLU2twS1EiLCJpc3N1ZWRBdCI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIiwiZXhwaXJlc0F0IjoiMjA5OS0wMS0wOFQwMDowMDowMFoiLCJjb25uZWN0S2V5SWQiOiIwNjA3NWY0ODRkY2U1YzA3NDVjYTc2NWE4ODk5MzA5YmQ3MTc2MTI5NDdhMzc3MGEwZDI3YjhjY2I4YmY5MDUyIiwidHJ1c3RDaGFpbiI6W3siYnl0ZXMiOiJleUptYjNKdFlYUldaWEp6YVc5dUlqb2ljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNJbkJ5YjNSdlkyOXNWbVZ5YzJsdmJpSTZJbll4SWl3aWMyVnlhV0ZzSWpvaVpUQTBZVEF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKeWIyeGxJam9pYVc1MFpYSnRaV1JwWVhSbElpd2lhWE56ZFdWeVMyVjVTV1FpT2lJNVlUVmpNemcxWVdKa09UZGpOMlppT0RSak5UUXdZalZtTldVME1tUTNPVGt4T1RNd1lUUTBOMk01TURJMk56azVaVGhtWkRSak5ESXpNR0kxWXpOaklpd2ljM1ZpYW1WamRFdGxlVWxrSWpvaVlUazFOVGxpWkRSbE1EazBPV0ZqWXpFeU1tRm1abU0xTldVMlpHSTRaVGt4Wmpoa01EazNaVFUxWldNNE9UbGtZVFl4T1RGbE5EWTVZelE0T0RKaE1pSXNJbk4xWW1wbFkzUlFkV0pzYVdOTFpYa2lPaUpDVG13elZFeDFTazV0Tld4NFRGUmFNRGxUWVdwWVRHMUZSM0ZqZHpGaVQyZzROVWhEYmpWdVVUazJWVlZUYUZVNFZHeEhVVWsxYmxaSldWUnJRWFpLYjFWRVMwWmhVVlp0WVZGVk9WRkhaemsyVDJ0RU1EUWlMQ0p1YjNSQ1pXWnZjbVVpT2lJeU1ESXdMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNJbTV2ZEVGbWRHVnlJam9pTWpBNU9TMHdNUzB3TVZRd01Eb3dNRG93TUZvaWZRPT0iLCJzaWduYXR1cmUiOnsiYWxnb3JpdGhtIjoiRVMyNTYiLCJrZXlJZCI6IjlhNWMzODVhYmQ5N2M3ZmI4NGM1NDBiNWY1ZTQyZDc5OTE5MzBhNDQ3YzkwMjY3OTllOGZkNGM0MjMwYjVjM2MiLCJ2YWx1ZSI6IklUSThKVGFURjRoRHJ0bF9tSk0zYmZ0eUlhMlJaSjFNNVpPVk1xaEE2cVJSSzFNcEcxQlF0emRRVzB6bHo4eG1RZUJNWl84clViTUlFcnFBel8tSWZRIn19LHsiYnl0ZXMiOiJleUptYjNKdFlYUldaWEp6YVc5dUlqb2ljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNJbkJ5YjNSdlkyOXNWbVZ5YzJsdmJpSTZJbll4SWl3aWMyVnlhV0ZzSWpvaVpUQTBZVEF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNRElpTENKeWIyeGxJam9pYzJsbmJtbHVaeUlzSW1semMzVmxja3RsZVVsa0lqb2lZVGsxTlRsaVpEUmxNRGswT1dGall6RXlNbUZtWm1NMU5XVTJaR0k0WlRreFpqaGtNRGszWlRVMVpXTTRPVGxrWVRZeE9URmxORFk1WXpRNE9ESmhNaUlzSW5OMVltcGxZM1JMWlhsSlpDSTZJakEyTURjMVpqUTROR1JqWlRWak1EYzBOV05oTnpZMVlUZzRPVGt6TURsaVpEY3hOell4TWprME4yRXpOemN3WVRCa01qZGlPR05qWWpoaVpqa3dOVElpTENKemRXSnFaV04wVUhWaWJHbGpTMlY1SWpvaVFrRk9lWFpITFdkR1YwdFRaM0pMWTNkUVpGUmhNRWN3TTBKdlVtRkNSbWhvZG10cWRFUjZlVk5tYm5weFYxcHROa2xLV0U1RWRVZEdlVXBFZVZWSmNYZzJSa1p5TlZSV2NsOXVhbUZCYzFWb2FEbE1VMlJ2SWl3aWJtOTBRbVZtYjNKbElqb2lNakF5TUMwd01TMHdNVlF3TURvd01Eb3dNRm9pTENKdWIzUkJablJsY2lJNklqSXdPVGt0TURFdE1ERlVNREE2TURBNk1EQmFJbjA9Iiwic2lnbmF0dXJlIjp7ImFsZ29yaXRobSI6IkVTMjU2Iiwia2V5SWQiOiJhOTU1OWJkNGUwOTQ5YWNjMTIyYWZmYzU1ZTZkYjhlOTFmOGQwOTdlNTVlYzg5OWRhNjE5MWU0NjljNDg4MmEyIiwidmFsdWUiOiI4MTVrVGNMY1hkZXZORFRia1dBck1KSnpORTVmaEdxbTNUb3hTM3Exdl9ZQkRVcmFieldmTFhjMEc5TjZiVXZFeEd3WEJUMVFpVzJvUDBCM3JKUS0ydyJ9fV19",
"signature": {
"algorithm": "ES256",
"keyId": "06075f484dce5c0745ca765a8899309bd717612947a3770a0d27b8ccb8bf9052",
"value": "8i2nvYhNsZbL1UPF82Otr4pcecYfKhdRV1Q7m3Geqcx-TdvYk486Q_cZHSQ7OaqnIhTZwPgMYFYKUuEhwAaYEQ"
}
}
@@ -0,0 +1,4 @@
{
"keyId": "9a5c385abd97c7fb84c540b5f5e42d7991930a447c9026799e8fd4c4230b5c3c",
"publicKey": "BIPyiA1W2NDuy3ftHLtlY2tO2WNjkjGINZ_9QQvyyN9syzbMb91QYG2SN0AqZPylFsTL-loF4M1tVySZtXJpKhM"
}
@@ -0,0 +1 @@
[{"bytes":"eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsInByb3RvY29sVmVyc2lvbiI6InYxIiwic2VyaWFsIjoiZTA0YTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyb2xlIjoiaW50ZXJtZWRpYXRlIiwiaXNzdWVyS2V5SWQiOiI5YTVjMzg1YWJkOTdjN2ZiODRjNTQwYjVmNWU0MmQ3OTkxOTMwYTQ0N2M5MDI2Nzk5ZThmZDRjNDIzMGI1YzNjIiwic3ViamVjdEtleUlkIjoiYTk1NTliZDRlMDk0OWFjYzEyMmFmZmM1NWU2ZGI4ZTkxZjhkMDk3ZTU1ZWM4OTlkYTYxOTFlNDY5YzQ4ODJhMiIsInN1YmplY3RQdWJsaWNLZXkiOiJCTmwzVEx1Sk5tNWx4TFRaMDlTYWpYTG1FR3FjdzFiT2g4NUhDbjVuUTk2VVVTaFU4VGxHUUk1blZJWVRrQXZKb1VES0ZhUVZtYVFVOVFHZzk2T2tEMDQiLCJub3RCZWZvcmUiOiIyMDIwLTAxLTAxVDAwOjAwOjAwWiIsIm5vdEFmdGVyIjoiMjA5OS0wMS0wMVQwMDowMDowMFoifQ==","signature":{"algorithm":"ES256","keyId":"9a5c385abd97c7fb84c540b5f5e42d7991930a447c9026799e8fd4c4230b5c3c","value":"ITI8JTaTF4hDrtl_mJM3bftyIa2RZJ1M5ZOVMqhA6qRRK1MpG1BQtzdQW0zlz8xmQeBMZ_8rUbMIErqAz_-IfQ"}},{"bytes":"eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsInByb3RvY29sVmVyc2lvbiI6InYxIiwic2VyaWFsIjoiZTA0YTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDIiLCJyb2xlIjoic2lnbmluZyIsImlzc3VlcktleUlkIjoiYTk1NTliZDRlMDk0OWFjYzEyMmFmZmM1NWU2ZGI4ZTkxZjhkMDk3ZTU1ZWM4OTlkYTYxOTFlNDY5YzQ4ODJhMiIsInN1YmplY3RLZXlJZCI6IjA2MDc1ZjQ4NGRjZTVjMDc0NWNhNzY1YTg4OTkzMDliZDcxNzYxMjk0N2EzNzcwYTBkMjdiOGNjYjhiZjkwNTIiLCJzdWJqZWN0UHVibGljS2V5IjoiQkFOeXZHLWdGV0tTZ3JLY3dQZFRhMEcwM0JvUmFCRmhodmtqdER6eVNmbnpxV1ptNklKWE5EdUdGeUpEeVVJcXg2RkZyNVRWcl9uamFBc1VoaDlMU2RvIiwibm90QmVmb3JlIjoiMjAyMC0wMS0wMVQwMDowMDowMFoiLCJub3RBZnRlciI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIn0=","signature":{"algorithm":"ES256","keyId":"a9559bd4e0949acc122affc55e6db8e91f8d097e55ec899da6191e469c4882a2","value":"815kTcLcXdevNDTbkWArMJJzNE5fhGqm3ToxS3q1v_YBDUrabzWfLXc0G9N6bUvExGwXBT1QiW2oP0B3rJQ-2w"}}]