diff --git a/rustfs/src/config/cli.rs b/rustfs/src/config/cli.rs index b23907ea1..0efaceba9 100644 --- a/rustfs/src/config/cli.rs +++ b/rustfs/src/config/cli.rs @@ -127,6 +127,8 @@ pub enum ConnectCommands { Register(ConnectRegisterOpts), /// Import, verify, or inspect a signed Connect service license License(ConnectLicenseOpts), + /// Deliver a reviewed signed artifact through the customer relay (Unix only) + Relay(Box), /// Read the persisted deployment inventory and collect an approved environment summary Inventory(ConnectInventoryOpts), /// Run an explicitly approved, bounded local performance measurement @@ -141,6 +143,61 @@ pub enum ConnectCommands { Top(ConnectTopOpts), } +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum ConnectRelayMaterialKind { + OfflineEnrollmentResponse, + DiagnosticBundleManifest, +} + +#[derive(Args, Clone)] +pub struct ConnectRelayOpts { + /// HTTPS Connect control API base ending in /api/ + #[arg(long, value_parser = NonEmptyStringValueParser::new())] + pub endpoint: String, + /// PEM root CA file used only for this Connect endpoint + #[arg(long = "ca-file")] + pub ca_file: PathBuf, + /// Owner-only file containing the browser Cookie header value + #[arg(long = "session-cookie-file")] + pub session_cookie_file: PathBuf, + /// Owner-only file containing the browser X-XSRF-TOKEN header value + #[arg(long = "csrf-token-file")] + pub csrf_token_file: PathBuf, + /// Organization UUIDv7 used by the approved Connect tenant + #[arg(long = "organization-uid", value_parser = NonEmptyStringValueParser::new())] + pub organization_uid: String, + /// Approval resource UID returned after customer review in Connect + #[arg(long = "approval-reference", value_parser = NonEmptyStringValueParser::new())] + pub approval_reference: String, + /// UUIDv7 identifying this exact relay attempt + #[arg(long = "transfer-uid", value_parser = NonEmptyStringValueParser::new())] + pub transfer_uid: String, + /// Allow-listed signed artifact type accepted by the Connect receiver + #[arg(long = "material-kind", value_enum)] + pub material_kind: ConnectRelayMaterialKind, + /// Owner-only signed artifact wrapper to transfer unchanged + #[arg(long)] + pub artifact: PathBuf, + /// Device or candidate-device resource name shown during review + #[arg(long = "producer-name", value_parser = NonEmptyStringValueParser::new())] + pub producer_name: String, + /// SHA-256 key ID of the device that signed the artifact + #[arg(long = "producer-key-id", value_parser = NonEmptyStringValueParser::new())] + pub producer_key_id: String, + /// Owner-only file containing the pinned receipt public key + #[arg(long = "receipt-public-key-file")] + pub receipt_public_key_file: PathBuf, + /// SHA-256 key ID of the pinned Connect receipt key + #[arg(long = "receipt-key-id", value_parser = NonEmptyStringValueParser::new())] + pub receipt_key_id: String, + /// Bounded HTTPS request timeout + #[arg(long = "timeout-seconds", default_value_t = 30)] + pub timeout_seconds: u64, + /// Confirm the artifact, producer, destination, digest and classification were reviewed + #[arg(long = "acknowledge-reviewed", required = true, action = clap::ArgAction::SetTrue)] + pub acknowledge_reviewed: bool, +} + #[derive(Args, Clone)] pub struct ConnectInventoryOpts { #[command(subcommand)] @@ -1316,6 +1373,8 @@ pub enum CommandResult { ConnectRegister(ConnectRegisterOpts), /// Local Connect service-license command ConnectLicense(ConnectLicenseCommands), + /// Customer-operated relay of one reviewed signed artifact + ConnectRelay(Box), /// Explicit local Connect environment inventory command ConnectEnvironmentInventory(ConnectEnvironmentInventoryOpts), /// Consent-bound local Connect drive performance export @@ -1374,8 +1433,8 @@ pub fn default_server_opts() -> ServerOpts { #[cfg(test)] mod tests { use super::{ - Cli, Commands, ConnectCommands, ConnectInventoryCommands, ConnectLicenseCommands, InspectCommands, - preprocess_args_for_legacy, + Cli, Commands, ConnectCommands, ConnectInventoryCommands, ConnectLicenseCommands, ConnectRelayMaterialKind, + InspectCommands, preprocess_args_for_legacy, }; use crate::version; use clap::error::ErrorKind; @@ -1525,6 +1584,27 @@ mod tests { assert_eq!(renew.scope.service_code, "SUPPORT"); } + #[test] + fn connect_relay_requires_protected_authentication_files_and_review() { + let cli = Cli::try_parse_from([ + "rustfs", "connect", "relay", "--endpoint", "https://connect.example/api/", "--ca-file", + "/etc/rustfs/connect-ca.pem", "--session-cookie-file", "/run/secrets/connect-cookie", "--csrf-token-file", + "/run/secrets/connect-csrf", "--organization-uid", "0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50", + "--approval-reference", "0198f3a1-b100-7a10-8a11-001122334455", "--transfer-uid", + "0198f3a1-a200-7b20-8b22-112233445566", "--material-kind", "diagnostic-bundle-manifest", "--artifact", + "/var/lib/rustfs/relay/manifest.json", "--producer-name", + "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61/clusterDevices/0198f3a1-6e00-7c30-ad41-2e3f4a5b6c72", + "--producer-key-id", "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf", + "--receipt-public-key-file", "/etc/rustfs/connect-relay.pub", "--receipt-key-id", + "aef7765496addd64bb9fcdd7b61682148622aed4856a7315326faea0aa86d53b", "--acknowledge-reviewed", + ]) + .expect("reviewed relay arguments should parse"); + let Some(Commands::Connect(connect)) = cli.command else { panic!("connect command expected") }; + let ConnectCommands::Relay(options) = connect.command else { panic!("relay command expected") }; + assert_eq!(options.material_kind, ConnectRelayMaterialKind::DiagnosticBundleManifest); + assert!(options.acknowledge_reviewed); + } + #[test] fn connect_register_has_no_token_value_or_environment_option() { for forbidden in ["--token", "--registration-token", "--token-env"] { diff --git a/rustfs/src/config/opt.rs b/rustfs/src/config/opt.rs index d65a038f7..09cf6728d 100644 --- a/rustfs/src/config/opt.rs +++ b/rustfs/src/config/opt.rs @@ -143,6 +143,7 @@ impl Opt { Some(Commands::Connect(opts)) => match opts.command { ConnectCommands::Register(opts) => Ok(CommandResult::ConnectRegister(opts)), ConnectCommands::License(opts) => Ok(CommandResult::ConnectLicense(opts.command)), + ConnectCommands::Relay(opts) => Ok(CommandResult::ConnectRelay(opts)), ConnectCommands::Inventory(opts) => match opts.command { ConnectInventoryCommands::Environment(opts) => Ok(CommandResult::ConnectEnvironmentInventory(opts)), }, diff --git a/rustfs/src/connect/mod.rs b/rustfs/src/connect/mod.rs index bec108bae..d58871d30 100644 --- a/rustfs/src/connect/mod.rs +++ b/rustfs/src/connect/mod.rs @@ -39,6 +39,7 @@ pub mod license_renewal; pub mod offline; pub mod registration; pub mod registration_bootstrap; +pub mod relay; pub mod runtime; mod telemetry; @@ -130,4 +131,9 @@ pub use license_renewal::{LicenseRenewalClient, LicenseRenewalError, LicenseRene pub use offline::{EnrollmentError, OfflineEnrollment, OfflineKeyStore, VerifiedChallenge}; pub use registration::{RegistrationToken, TokenError}; pub use registration_bootstrap::{RegistrationBootstrapError, RegistrationBootstrapResult, register_from_protected_input}; +pub use relay::{ + RelayDirection, RelayError, RelayHttpClient, RelayMaterialKind, RelayParty, RelayReceiptOutcome, RelayReceiptPayload, + RelayReview, TrustedReceiptSigner, prepare_approved_artifact, read_protected_relay_artifact, + read_protected_relay_authentication, +}; pub use runtime::{HeartbeatRuntime, InventoryRuntime, spawn_heartbeat_runtime, spawn_inventory_runtime}; diff --git a/rustfs/src/connect/relay.rs b/rustfs/src/connect/relay.rs new file mode 100644 index 000000000..f39641b99 --- /dev/null +++ b/rustfs/src/connect/relay.rs @@ -0,0 +1,623 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Customer-operated transfer of opaque, signed Connect material. +//! +//! The relay has no signing authority. It carries the exact approved bytes and +//! accepts success only from a receipt signed by the preconfigured destination. + +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signature, VerifyingKey}; +use reqwest::{Client, StatusCode, Url, header}; +use rustls::pki_types::{CertificateDer, pem::PemObject as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::Read as _; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::Path; +use std::time::Duration; +use uuid::{Uuid, Variant, Version}; + +use super::client::build_client; +use super::config::ProxyConfig; + +pub const RELAY_ENVELOPE_FORMAT: &str = "rustfs.connect.relayEnvelope/1"; +pub const RELAY_RECEIPT_FORMAT: &str = "rustfs.connect.relayReceipt/1"; +pub const RELAY_RECEIPT_DOMAIN_SEPARATION_TAG: &str = "rustfs-connect-relay-receipt-v1"; +pub const MAX_RELAY_ARTIFACT_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_DELIVERY_ATTEMPTS: usize = 3; +const MAX_RECEIPT_BYTES: usize = 64 * 1024; +const RETRY_DELAY: Duration = Duration::from_millis(250); +const MAX_AUTHENTICATION_BYTES: u64 = 8 * 1024; +const MAX_PUBLIC_KEY_BYTES: u64 = 256; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RelayMaterialKind { + OfflineEnrollmentResponse, + DiagnosticBundleManifest, + ServiceLicense, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RelayDirection { + ClusterToConnect, + ConnectToCluster, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct RelayParty { + #[serde(rename = "type")] + pub party_type: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct RelayArtifact { + pub encoding: String, + pub bytes: String, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct RelayEnvelope { + pub format_version: String, + pub protocol_version: String, + pub transfer_uid: String, + pub material_kind: RelayMaterialKind, + pub direction: RelayDirection, + pub artifact: RelayArtifact, + pub asserted_producer: RelayParty, + pub destination: RelayParty, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayReview { + pub transfer_uid: String, + pub material_kind: RelayMaterialKind, + pub direction: RelayDirection, + pub artifact_sha256: String, + pub artifact_size_bytes: usize, + pub asserted_producer: RelayParty, + pub destination: RelayParty, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RelayReceiptOutcome { + Applied, + Duplicate, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct RelayReceiptPayload { + pub format_version: String, + pub protocol_version: String, + pub transfer_uid: String, + pub material_kind: RelayMaterialKind, + pub direction: RelayDirection, + pub artifact_sha256: String, + pub producer: RelayParty, + pub destination: RelayParty, + pub outcome: RelayReceiptOutcome, + pub received_at: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +struct RelayReceiptSignature { + algorithm: String, + key_id: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SignedRelayReceipt { + payload: String, + signature: RelayReceiptSignature, +} + +#[derive(Clone, Debug)] +pub struct TrustedReceiptSigner { + key_id: String, + verifying_key: VerifyingKey, +} + +impl TrustedReceiptSigner { + pub fn new(key_id: String, public_key: [u8; 32]) -> Result { + if !is_sha256(&key_id) || hex_lower(&Sha256::digest(public_key)) != key_id { + return Err(RelayError::ReceiptTrustInvalid); + } + let verifying_key = VerifyingKey::from_bytes(&public_key).map_err(|_| RelayError::ReceiptTrustInvalid)?; + Ok(Self { key_id, verifying_key }) + } + + pub fn from_public_key_file(path: &Path, key_id: String) -> Result { + let encoded = read_protected_bytes(path, MAX_PUBLIC_KEY_BYTES)?; + let encoded = std::str::from_utf8(&encoded) + .map_err(|_| RelayError::ReceiptTrustInvalid)? + .trim(); + let public_key = decode_canonical_base64url(encoded).ok_or(RelayError::ReceiptTrustInvalid)?; + let public_key: [u8; 32] = public_key.try_into().map_err(|_| RelayError::ReceiptTrustInvalid)?; + Self::new(key_id, public_key) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct RelayReplayKey { + material_kind: RelayMaterialKind, + artifact_sha256: String, + destination_type: String, + destination_name: String, +} + +impl RelayEnvelope { + /// The durable destination uses this key after material-specific signature, + /// freshness, revocation, and scope verification. It must be persisted with + /// the terminal receipt before acknowledging a side effect. + pub fn replay_key(&self) -> RelayReplayKey { + RelayReplayKey { + material_kind: self.material_kind, + artifact_sha256: self.artifact.sha256.clone(), + destination_type: self.destination.party_type.clone(), + destination_name: self.destination.name.clone(), + } + } +} + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum RelayError { + #[error("the relay material kind or direction is unsupported")] + UnsupportedMaterial, + #[error("the relay transfer identifier is not a canonical UUIDv7")] + InvalidTransferUid, + #[error("the relay producer or destination identity is invalid")] + InvalidParty, + #[error("the relay artifact is empty or exceeds its size limit")] + InvalidArtifact, + #[error("the customer did not approve this relay transfer")] + ApprovalRequired, + #[error("the relay envelope could not be encoded")] + EnvelopeEncoding, + #[error("the destination receipt trust configuration is invalid")] + ReceiptTrustInvalid, + #[error("the destination receipt is malformed")] + ReceiptInvalid, + #[error("the destination receipt key is not trusted")] + ReceiptSignerUntrusted, + #[error("the destination receipt signature is invalid")] + ReceiptSignatureInvalid, + #[error("the destination receipt does not bind this transfer")] + ReceiptMismatch, + #[error("delivery produced no verified receipt after three attempts")] + DeliveryUnknown, + #[error("the relay control API rejected delivery with HTTP {0}")] + DeliveryRejected(u16), + #[error("the relay control API response exceeded its size limit")] + ResponseTooLarge, + #[error("the relay HTTP authentication header is invalid")] + AuthenticationInvalid, + #[error("the relay input file is unavailable or exceeds its size limit")] + InputFile, + #[error("the relay credential file is not an owner-only regular file")] + CredentialFileSecurity, + #[error("the relay HTTPS client configuration is invalid")] + ClientConfiguration, + #[error("the relay HTTPS request failed")] + Transport, +} + +pub trait RelayTransport { + fn deliver(&mut self, envelope: &[u8]) -> Result>, ()>; +} + +#[derive(Debug)] +pub struct RelayDelivery { + pub review: RelayReview, + pub envelope: RelayEnvelope, + pub receipt: RelayReceiptPayload, + pub receipt_bytes: Vec, + pub attempts: usize, +} + +pub struct PreparedRelay { + pub review: RelayReview, + pub envelope: RelayEnvelope, +} + +pub struct RelayHttpClient { + client: Client, + receive_url: Url, + cookie: header::HeaderValue, + csrf_token: header::HeaderValue, + approval_reference: String, +} + +impl RelayHttpClient { + #[allow(clippy::too_many_arguments)] + pub fn new( + endpoint: &str, + root_ca_pem: &[u8], + organization_uid: &str, + approval_reference: String, + cookie: &str, + csrf_token: &str, + timeout: Duration, + proxy: Option<&ProxyConfig>, + ) -> Result { + let endpoint = Url::parse(endpoint).map_err(|_| RelayError::ClientConfiguration)?; + if endpoint.scheme() != "https" + || endpoint.cannot_be_a_base() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || !endpoint.path().ends_with("/api/") + || !is_uuid_v7(organization_uid) + || approval_reference.is_empty() + || approval_reference.len() > 512 + { + return Err(RelayError::ClientConfiguration); + } + let receive_url = endpoint + .join(&format!("organizations/{organization_uid}/relayTransfers:receive")) + .map_err(|_| RelayError::ClientConfiguration)?; + let roots = CertificateDer::pem_slice_iter(root_ca_pem) + .collect::, _>>() + .map_err(|_| RelayError::ClientConfiguration)?; + if roots.is_empty() { + return Err(RelayError::ClientConfiguration); + } + let client = build_client(&roots, timeout, None, proxy).map_err(|_| RelayError::ClientConfiguration)?; + let mut cookie = header::HeaderValue::from_str(cookie).map_err(|_| RelayError::AuthenticationInvalid)?; + let mut csrf_token = header::HeaderValue::from_str(csrf_token).map_err(|_| RelayError::AuthenticationInvalid)?; + cookie.set_sensitive(true); + csrf_token.set_sensitive(true); + Ok(Self { + client, + receive_url, + cookie, + csrf_token, + approval_reference, + }) + } + + pub async fn deliver( + &self, + prepared: PreparedRelay, + trusted_receipt_signer: &TrustedReceiptSigner, + ) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Request<'a> { + approval_reference: &'a str, + envelope: &'a RelayEnvelope, + } + + for attempts in 1..=MAX_DELIVERY_ATTEMPTS { + let response = self + .client + .post(self.receive_url.clone()) + .header(header::COOKIE, self.cookie.clone()) + .header("X-XSRF-TOKEN", self.csrf_token.clone()) + .header(header::ACCEPT, "application/json") + .json(&Request { + approval_reference: &self.approval_reference, + envelope: &prepared.envelope, + }) + .send() + .await; + let response = match response { + Ok(response) => response, + Err(_) if attempts < MAX_DELIVERY_ATTEMPTS => { + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + Err(_) => return Err(RelayError::Transport), + }; + if retryable_status(response.status()) && attempts < MAX_DELIVERY_ATTEMPTS { + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + if response.status() != StatusCode::OK { + return Err(RelayError::DeliveryRejected(response.status().as_u16())); + } + let receipt_bytes = bounded_body(response).await?; + let receipt = verify_receipt(&receipt_bytes, &prepared.envelope, trusted_receipt_signer)?; + return Ok(RelayDelivery { + review: prepared.review, + envelope: prepared.envelope, + receipt, + receipt_bytes, + attempts, + }); + } + Err(RelayError::DeliveryUnknown) + } +} + +#[allow(clippy::too_many_arguments)] +/// Build, approve, and deliver one relay envelope without changing the signed +/// material bytes. Producer fields are advisory until the destination derives +/// and compares them using its material-specific verifier. +pub fn relay_approved_artifact( + transfer_uid: &str, + material_kind: RelayMaterialKind, + artifact_bytes: &[u8], + asserted_producer: RelayParty, + destination: RelayParty, + trusted_receipt_signer: &TrustedReceiptSigner, + approve: A, + transport: &mut T, +) -> Result +where + T: RelayTransport, + A: FnOnce(&RelayReview) -> bool, +{ + let prepared = + prepare_approved_artifact(transfer_uid, material_kind, artifact_bytes, asserted_producer, destination, approve)?; + let PreparedRelay { review, envelope } = prepared; + let encoded = serde_json::to_vec(&envelope).map_err(|_| RelayError::EnvelopeEncoding)?; + + for attempts in 1..=MAX_DELIVERY_ATTEMPTS { + let Ok(Some(receipt_bytes)) = transport.deliver(&encoded) else { + continue; + }; + let receipt = verify_receipt(&receipt_bytes, &envelope, trusted_receipt_signer)?; + return Ok(RelayDelivery { + review, + envelope, + receipt, + receipt_bytes, + attempts, + }); + } + Err(RelayError::DeliveryUnknown) +} + +pub fn prepare_approved_artifact( + transfer_uid: &str, + material_kind: RelayMaterialKind, + artifact_bytes: &[u8], + asserted_producer: RelayParty, + destination: RelayParty, + approve: A, +) -> Result +where + A: FnOnce(&RelayReview) -> bool, +{ + validate_transfer_uid(transfer_uid)?; + validate_route(material_kind, &asserted_producer, &destination)?; + if artifact_bytes.is_empty() || artifact_bytes.len() > MAX_RELAY_ARTIFACT_BYTES { + return Err(RelayError::InvalidArtifact); + } + + let artifact_sha256 = hex_lower(&Sha256::digest(artifact_bytes)); + let review = RelayReview { + transfer_uid: transfer_uid.to_owned(), + material_kind, + direction: direction_for(material_kind), + artifact_sha256: artifact_sha256.clone(), + artifact_size_bytes: artifact_bytes.len(), + asserted_producer: asserted_producer.clone(), + destination: destination.clone(), + }; + if !approve(&review) { + return Err(RelayError::ApprovalRequired); + } + + let envelope = RelayEnvelope { + format_version: RELAY_ENVELOPE_FORMAT.to_owned(), + protocol_version: "v1".to_owned(), + transfer_uid: transfer_uid.to_owned(), + material_kind, + direction: review.direction, + artifact: RelayArtifact { + encoding: "base64".to_owned(), + bytes: BASE64_STANDARD.encode_to_string(artifact_bytes), + sha256: artifact_sha256, + }, + asserted_producer, + destination, + }; + Ok(PreparedRelay { review, envelope }) +} + +pub fn verify_receipt( + receipt_bytes: &[u8], + envelope: &RelayEnvelope, + trusted_signer: &TrustedReceiptSigner, +) -> Result { + let signed: SignedRelayReceipt = serde_json::from_slice(receipt_bytes).map_err(|_| RelayError::ReceiptInvalid)?; + if signed.signature.algorithm != "Ed25519" || signed.signature.key_id != trusted_signer.key_id { + return Err(RelayError::ReceiptSignerUntrusted); + } + + let payload = decode_canonical_base64url(&signed.payload).ok_or(RelayError::ReceiptInvalid)?; + let signature = decode_canonical_base64url(&signed.signature.value).ok_or(RelayError::ReceiptInvalid)?; + let signature: [u8; 64] = signature.try_into().map_err(|_| RelayError::ReceiptInvalid)?; + let mut signed_bytes = Vec::with_capacity(RELAY_RECEIPT_DOMAIN_SEPARATION_TAG.len() + 1 + payload.len()); + signed_bytes.extend_from_slice(RELAY_RECEIPT_DOMAIN_SEPARATION_TAG.as_bytes()); + signed_bytes.push(0); + signed_bytes.extend_from_slice(&payload); + trusted_signer + .verifying_key + .verify_strict(&signed_bytes, &Signature::from_bytes(&signature)) + .map_err(|_| RelayError::ReceiptSignatureInvalid)?; + + let receipt: RelayReceiptPayload = serde_json::from_slice(&payload).map_err(|_| RelayError::ReceiptInvalid)?; + if receipt.format_version != RELAY_RECEIPT_FORMAT + || receipt.protocol_version != envelope.protocol_version + || receipt.transfer_uid != envelope.transfer_uid + || receipt.material_kind != envelope.material_kind + || receipt.direction != envelope.direction + || receipt.artifact_sha256 != envelope.artifact.sha256 + || receipt.producer != envelope.asserted_producer + || receipt.destination != envelope.destination + { + return Err(RelayError::ReceiptMismatch); + } + Ok(receipt) +} + +fn validate_route( + material_kind: RelayMaterialKind, + asserted_producer: &RelayParty, + destination: &RelayParty, +) -> Result<(), RelayError> { + if !valid_party(asserted_producer) || !valid_party(destination) { + return Err(RelayError::InvalidParty); + } + let valid = match material_kind { + RelayMaterialKind::OfflineEnrollmentResponse | RelayMaterialKind::DiagnosticBundleManifest => { + asserted_producer.party_type == "DEVICE" && asserted_producer.key_id.is_some() && destination.party_type == "CONNECT" + } + RelayMaterialKind::ServiceLicense => { + asserted_producer.party_type == "CONNECT_LICENSE_ISSUER" + && asserted_producer.key_id.is_some() + && destination.party_type == "CLUSTER" + } + }; + valid.then_some(()).ok_or(RelayError::UnsupportedMaterial) +} + +const fn direction_for(material_kind: RelayMaterialKind) -> RelayDirection { + match material_kind { + RelayMaterialKind::OfflineEnrollmentResponse | RelayMaterialKind::DiagnosticBundleManifest => { + RelayDirection::ClusterToConnect + } + RelayMaterialKind::ServiceLicense => RelayDirection::ConnectToCluster, + } +} + +fn valid_party(party: &RelayParty) -> bool { + !party.party_type.is_empty() + && party.party_type.len() <= 64 + && !party.name.is_empty() + && party.name.len() <= 1024 + && party.key_id.as_deref().is_none_or(is_sha256) +} + +fn validate_transfer_uid(value: &str) -> Result<(), RelayError> { + is_uuid_v7(value).then_some(()).ok_or(RelayError::InvalidTransferUid) +} + +fn is_uuid_v7(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|uuid| { + uuid.get_version() == Some(Version::SortRand) && uuid.get_variant() == Variant::RFC4122 && uuid.to_string() == value + }) +} + +fn retryable_status(status: StatusCode) -> bool { + matches!(status.as_u16(), 408 | 425 | 429 | 500 | 502 | 503 | 504) +} + +async fn bounded_body(mut response: reqwest::Response) -> Result, RelayError> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| RelayError::Transport)? { + if body.len().saturating_add(chunk.len()) > MAX_RECEIPT_BYTES { + return Err(RelayError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +pub fn read_protected_relay_artifact(path: &Path) -> Result, RelayError> { + read_protected_bytes(path, MAX_RELAY_ARTIFACT_BYTES as u64) +} + +pub fn read_protected_relay_authentication(path: &Path) -> Result { + let bytes = read_protected_bytes(path, MAX_AUTHENTICATION_BYTES)?; + let value = std::str::from_utf8(&bytes) + .map_err(|_| RelayError::AuthenticationInvalid)? + .trim() + .to_owned(); + if value.is_empty() || value.bytes().any(|byte| byte == b'\r' || byte == b'\n') { + return Err(RelayError::AuthenticationInvalid); + } + Ok(value) +} + +#[cfg(unix)] +fn read_protected_bytes(path: &Path, maximum: u64) -> Result, RelayError> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|_| RelayError::InputFile)?; + let metadata = file.metadata().map_err(|_| RelayError::InputFile)?; + if !metadata.is_file() || metadata.uid() != process_uid() || metadata.permissions().mode() & 0o077 != 0 { + return Err(RelayError::CredentialFileSecurity); + } + if metadata.len() == 0 || metadata.len() > maximum { + return Err(RelayError::InputFile); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(maximum + 1) + .read_to_end(&mut bytes) + .map_err(|_| RelayError::InputFile)?; + if bytes.is_empty() || bytes.len() as u64 > maximum { + return Err(RelayError::InputFile); + } + Ok(bytes) +} + +#[cfg(unix)] +#[allow(unsafe_code)] +fn process_uid() -> u32 { + // SAFETY: geteuid has no pointer arguments or caller preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(not(unix))] +fn read_protected_bytes(_path: &Path, _maximum: u64) -> Result, RelayError> { + Err(RelayError::CredentialFileSecurity) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn decode_canonical_base64url(value: &str) -> Option> { + if value.contains('=') { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode_to_vec(value.as_bytes()).ok()?; + (URL_SAFE_NO_PAD.encode_to_string(&decoded) == value).then_some(decoded) +} + +fn hex_lower(bytes: &[u8]) -> String { + hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower) +} diff --git a/rustfs/src/startup_entrypoint.rs b/rustfs/src/startup_entrypoint.rs index 93430fee3..ecf7e2821 100644 --- a/rustfs/src/startup_entrypoint.rs +++ b/rustfs/src/startup_entrypoint.rs @@ -17,8 +17,8 @@ use crate::{ CommandResult, Config, ConnectClientPerformanceOperation, ConnectClientPerformanceOpts, ConnectDrivePerformanceOpts, ConnectEnvironmentInventoryOpts, ConnectLicenseCommands, ConnectLicenseScopeOpts, ConnectLogsMode, ConnectLogsOpts, ConnectObjectPerformanceOperation, ConnectObjectPerformanceOpts, ConnectProfileOpts, ConnectProfileTool, - ConnectSiteReplicationPerformanceOpts, ConnectTelemetryArtifactOpts, ConnectTelemetryCommands, ConnectThreadProfileScope, - ConnectTopCommands, Opt, + ConnectRelayMaterialKind, ConnectRelayOpts, ConnectSiteReplicationPerformanceOpts, ConnectTelemetryArtifactOpts, + ConnectTelemetryCommands, ConnectThreadProfileScope, ConnectTopCommands, Opt, }, startup_lifecycle::{StartupRuntimeLifecycle, run_startup_runtime_lifecycle}, startup_preflight::{StartupServerPreflightError, bootstrap_external_prefix_compat, init_startup_server_preflight}, @@ -138,6 +138,7 @@ async fn async_main() -> Result<()> { return Ok(()); } CommandResult::ConnectLicense(command) => return execute_connect_license(command).await, + CommandResult::ConnectRelay(options) => return execute_connect_relay(*options).await, CommandResult::ConnectEnvironmentInventory(options) => return execute_connect_environment_inventory(options).await, CommandResult::ConnectClientPerformance(options) => return execute_connect_client_performance(options).await, CommandResult::ConnectDrivePerformance(options) => return execute_connect_drive_performance(options).await, @@ -1074,6 +1075,18 @@ fn read_optional_root_ca(path: Option<&std::path::Path>, label: &str) -> Result< Ok(Some(bytes)) } +fn read_relay_root_ca(path: &std::path::Path) -> Result> { + const MAX_ROOT_CA_BYTES: u64 = 1_048_576; + let mut bytes = Vec::with_capacity(16 * 1024); + std::fs::File::open(path)? + .take(MAX_ROOT_CA_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.is_empty() || bytes.len() as u64 > MAX_ROOT_CA_BYTES { + return Err(Error::other("connect relay root CA is empty or exceeds the 1048576-byte limit")); + } + Ok(bytes) +} + async fn execute_connect_drive_performance(options: ConnectDrivePerformanceOpts) -> Result<()> { use crate::connect::{ DriveOutcome, DrivePerformanceRequest, DriveProvenance, IdentityStore, LocalDriveConsent, measure_drive, @@ -1399,6 +1412,68 @@ async fn execute_connect_license(command: ConnectLicenseCommands) -> Result<()> } } +async fn execute_connect_relay(options: ConnectRelayOpts) -> Result<()> { + use crate::connect::{ + ProxyConfig, RelayHttpClient, RelayMaterialKind, RelayParty, TrustedReceiptSigner, prepare_approved_artifact, + read_protected_relay_artifact, read_protected_relay_authentication, + }; + + if options.timeout_seconds == 0 || options.timeout_seconds > 300 { + return Err(Error::other("relay timeout must be between 1 and 300 seconds")); + } + let artifact = read_protected_relay_artifact(&options.artifact).map_err(Error::other)?; + let root_ca_pem = read_relay_root_ca(&options.ca_file)?; + let cookie = read_protected_relay_authentication(&options.session_cookie_file).map_err(Error::other)?; + let csrf_token = read_protected_relay_authentication(&options.csrf_token_file).map_err(Error::other)?; + let receipt_trust = TrustedReceiptSigner::from_public_key_file(&options.receipt_public_key_file, options.receipt_key_id) + .map_err(Error::other)?; + let material_kind = match options.material_kind { + ConnectRelayMaterialKind::OfflineEnrollmentResponse => RelayMaterialKind::OfflineEnrollmentResponse, + ConnectRelayMaterialKind::DiagnosticBundleManifest => RelayMaterialKind::DiagnosticBundleManifest, + }; + let organization_name = format!("organizations/{}", options.organization_uid); + let prepared = prepare_approved_artifact( + &options.transfer_uid, + material_kind, + &artifact, + RelayParty { + party_type: "DEVICE".to_owned(), + name: options.producer_name, + key_id: Some(options.producer_key_id), + }, + RelayParty { + party_type: "CONNECT".to_owned(), + name: organization_name, + key_id: None, + }, + |_| options.acknowledge_reviewed, + ) + .map_err(Error::other)?; + let proxy = ProxyConfig::from_env().map_err(Error::other)?; + let client = RelayHttpClient::new( + &options.endpoint, + &root_ca_pem, + &options.organization_uid, + options.approval_reference, + &cookie, + &csrf_token, + Duration::from_secs(options.timeout_seconds), + proxy.as_ref(), + ) + .map_err(Error::other)?; + let delivery = client.deliver(prepared, &receipt_trust).await.map_err(Error::other)?; + let receipt: serde_json::Value = serde_json::from_slice(&delivery.receipt_bytes).map_err(Error::other)?; + println!( + "{}", + serde_json::json!({ + "attempts": delivery.attempts, + "receipt": receipt, + "review": delivery.review, + }) + ); + Ok(()) +} + fn license_context( scope: &ConnectLicenseScopeOpts, ) -> std::result::Result { diff --git a/rustfs/tests/connect_relay.rs b/rustfs/tests/connect_relay.rs new file mode 100644 index 000000000..192df2df7 --- /dev/null +++ b/rustfs/tests/connect_relay.rs @@ -0,0 +1,300 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::HashMap; + +use base64_simd::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signer as _, SigningKey}; +use rustfs::connect::relay; +use rustfs::connect::relay::{ + RelayDirection, RelayEnvelope, RelayError, RelayMaterialKind, RelayParty, RelayReceiptOutcome, RelayReceiptPayload, + RelayReplayKey, RelayTransport, TrustedReceiptSigner, relay_approved_artifact, +}; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +const TRANSFER_UID: &str = "0198f3a1-a200-7b20-8b22-112233445566"; + +struct Destination { + signing_key: SigningKey, + transfers: HashMap, + applied: HashMap, + interrupted: bool, + side_effects: usize, +} + +impl Destination { + fn new(signing_key: SigningKey, interrupted: bool) -> Self { + Self { + signing_key, + transfers: HashMap::new(), + applied: HashMap::new(), + interrupted, + side_effects: 0, + } + } + + fn receipt(&self, envelope: &RelayEnvelope, outcome: RelayReceiptOutcome) -> Vec { + let payload = RelayReceiptPayload { + format_version: relay::RELAY_RECEIPT_FORMAT.to_owned(), + protocol_version: "v1".to_owned(), + transfer_uid: envelope.transfer_uid.clone(), + material_kind: envelope.material_kind, + direction: envelope.direction, + artifact_sha256: envelope.artifact.sha256.clone(), + producer: envelope.asserted_producer.clone(), + destination: envelope.destination.clone(), + outcome, + received_at: "2026-08-20T09:30:01Z".to_owned(), + }; + let payload = serde_json::to_vec(&payload).unwrap(); + let encoded_payload = URL_SAFE_NO_PAD.encode_to_string(&payload); + let mut signed = b"rustfs-connect-relay-receipt-v1\0".to_vec(); + signed.extend_from_slice(&payload); + let signature = self.signing_key.sign(&signed).to_bytes(); + let key_id = hex_lower(&Sha256::digest(self.signing_key.verifying_key().as_bytes())); + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Signature<'a> { + algorithm: &'static str, + key_id: String, + value: String, + #[serde(skip)] + _marker: std::marker::PhantomData<&'a ()>, + } + #[derive(Serialize)] + struct Receipt<'a> { + payload: String, + signature: Signature<'a>, + } + serde_json::to_vec(&Receipt { + payload: encoded_payload, + signature: Signature { + algorithm: "Ed25519", + key_id, + value: URL_SAFE_NO_PAD.encode_to_string(signature), + _marker: std::marker::PhantomData, + }, + }) + .unwrap() + } +} + +impl RelayTransport for Destination { + fn deliver(&mut self, bytes: &[u8]) -> Result>, ()> { + let envelope: RelayEnvelope = serde_json::from_slice(bytes).unwrap(); + let key = envelope.replay_key(); + if self + .transfers + .get(&envelope.transfer_uid) + .is_some_and(|existing| existing != &key) + { + return Err(()); + } + self.transfers + .entry(envelope.transfer_uid.clone()) + .or_insert_with(|| key.clone()); + let duplicate = self.applied.contains_key(&key); + if !duplicate { + self.side_effects += 1; + self.applied.insert(key, envelope.transfer_uid.clone()); + } + if self.interrupted { + self.interrupted = false; + return Ok(None); + } + let outcome = if duplicate { + RelayReceiptOutcome::Duplicate + } else { + RelayReceiptOutcome::Applied + }; + Ok(Some(self.receipt(&envelope, outcome))) + } +} + +fn party(party_type: &str, name: &str, key_id: bool) -> RelayParty { + RelayParty { + party_type: party_type.to_owned(), + name: name.to_owned(), + key_id: key_id.then(|| "39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf".to_owned()), + } +} + +fn trusted(key: &SigningKey) -> TrustedReceiptSigner { + let public_key = key.verifying_key().to_bytes(); + TrustedReceiptSigner::new(hex_lower(&Sha256::digest(public_key)), public_key).unwrap() +} + +#[test] +fn interrupted_transfer_retries_exact_bytes_and_applies_once() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let mut destination = Destination::new(signing_key.clone(), true); + let artifact = b"opaque signed diagnostic manifest"; + let delivery = relay_approved_artifact( + TRANSFER_UID, + RelayMaterialKind::DiagnosticBundleManifest, + artifact, + party("DEVICE", "organizations/o/clusters/c/clusterDevices/d", true), + party("CONNECT", "organizations/o", false), + &trusted(&signing_key), + |review| review.artifact_sha256 == hex_lower(&Sha256::digest(artifact)), + &mut destination, + ) + .unwrap(); + + assert_eq!(delivery.attempts, 2); + assert_eq!(delivery.receipt.outcome, RelayReceiptOutcome::Duplicate); + assert_eq!(destination.side_effects, 1); + assert_eq!(delivery.envelope.direction, RelayDirection::ClusterToConnect); + assert_eq!( + BASE64_STANDARD + .decode_to_vec(delivery.envelope.artifact.bytes.as_bytes()) + .unwrap(), + artifact + ); +} + +#[test] +fn customer_rejection_prevents_delivery() { + let signing_key = SigningKey::from_bytes(&[8; 32]); + let mut destination = Destination::new(signing_key.clone(), false); + let result = relay_approved_artifact( + TRANSFER_UID, + RelayMaterialKind::OfflineEnrollmentResponse, + b"signed enrollment response", + party("DEVICE", "organizations/o/clusters/c/candidateDevices/d", true), + party("CONNECT", "organizations/o", false), + &trusted(&signing_key), + |_| false, + &mut destination, + ); + assert_eq!(result.unwrap_err(), RelayError::ApprovalRequired); + assert_eq!(destination.side_effects, 0); +} + +#[test] +fn receipt_from_an_untrusted_destination_is_rejected() { + let trusted_key = SigningKey::from_bytes(&[9; 32]); + let relay_key = SigningKey::from_bytes(&[10; 32]); + let mut destination = Destination::new(relay_key, false); + let result = relay_approved_artifact( + TRANSFER_UID, + RelayMaterialKind::ServiceLicense, + b"signed license", + party("CONNECT_LICENSE_ISSUER", "issuer", true), + party("CLUSTER", "organizations/o/clusters/c", false), + &trusted(&trusted_key), + |_| true, + &mut destination, + ); + assert_eq!(result.unwrap_err(), RelayError::ReceiptSignerUntrusted); + assert_eq!(destination.side_effects, 1); +} + +#[test] +fn transfer_uid_reuse_with_different_material_conflicts() { + let producer = party("DEVICE", "organizations/o/clusters/c/clusterDevices/d", true); + let destination = party("CONNECT", "organizations/o", false); + let signing_key = SigningKey::from_bytes(&[12; 32]); + let mut relay_destination = Destination::new(signing_key, false); + let first = envelope(b"first", producer.clone(), destination.clone()); + assert!(relay_destination.deliver(&serde_json::to_vec(&first).unwrap()).is_ok()); + assert!(relay_destination.deliver(&serde_json::to_vec(&first).unwrap()).is_ok()); + let mut conflicting = first; + conflicting.artifact.bytes = BASE64_STANDARD.encode_to_string(b"second"); + conflicting.artifact.sha256 = hex_lower(&Sha256::digest(b"second")); + assert!(relay_destination.deliver(&serde_json::to_vec(&conflicting).unwrap()).is_err()); + assert_eq!(relay_destination.side_effects, 1); +} + +#[test] +fn unknown_material_and_changed_receipt_are_fail_closed() { + let json = br#"{"formatVersion":"rustfs.connect.relayEnvelope/1","protocolVersion":"v1","transferUid":"0198f3a1-a200-7b20-8b22-112233445566","materialKind":"OBJECT_DATA","direction":"CLUSTER_TO_CONNECT","artifact":{"encoding":"base64","bytes":"eA","sha256":"2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881"},"assertedProducer":{"type":"DEVICE","name":"d","keyId":"39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf"},"destination":{"type":"CONNECT","name":"o"}}"#; + assert!(serde_json::from_slice::(json).is_err()); + + let signing_key = SigningKey::from_bytes(&[11; 32]); + let destination = Destination::new(signing_key.clone(), false); + let envelope = envelope( + b"signed", + party("DEVICE", "organizations/o/clusters/c/clusterDevices/d", true), + party("CONNECT", "organizations/o", false), + ); + let receipt = destination.receipt(&envelope, RelayReceiptOutcome::Applied); + let mut receipt: serde_json::Value = serde_json::from_slice(&receipt).unwrap(); + receipt["payload"] = serde_json::Value::String("e30".to_owned()); + assert_eq!( + relay::verify_receipt(&serde_json::to_vec(&receipt).unwrap(), &envelope, &trusted(&signing_key)).unwrap_err(), + RelayError::ReceiptSignatureInvalid + ); +} + +#[test] +fn verifies_the_frozen_connect_diagnostic_receipt() { + let envelope = RelayEnvelope { + format_version: relay::RELAY_ENVELOPE_FORMAT.to_owned(), + protocol_version: "v1".to_owned(), + transfer_uid: TRANSFER_UID.to_owned(), + material_kind: RelayMaterialKind::DiagnosticBundleManifest, + direction: RelayDirection::ClusterToConnect, + artifact: relay::RelayArtifact { + encoding: "base64".to_owned(), + bytes: "unused by receipt verification".to_owned(), + sha256: "3e16c840167f7ea3344e9d2cbd36b2e3f78fc505ba17fed3368f88fdc72b5c7e".to_owned(), + }, + asserted_producer: RelayParty { + party_type: "DEVICE".to_owned(), + name: "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50/clusters/0198f3a1-5d00-7b20-9c31-1d2e3f4a5b61/clusterDevices/0198f3a1-6e00-7c30-ad41-2e3f4a5b6c72".to_owned(), + key_id: Some("39ca24c8b02a559fd9beb2b1f5d18ced20c4bb246577b92914ae6814c3f70acf".to_owned()), + }, + destination: RelayParty { + party_type: "CONNECT".to_owned(), + name: "organizations/0198f3a1-4c00-7a10-8b21-0c1d2e3f4a50".to_owned(), + key_id: None, + }, + }; + let public_key = URL_SAFE_NO_PAD + .decode_to_vec(b"ClL2RdbJCGTGikXEDuSFpivrCjV0aT6EibHPecUkGB0") + .unwrap() + .try_into() + .unwrap(); + let trust = + TrustedReceiptSigner::new("aef7765496addd64bb9fcdd7b61682148622aed4856a7315326faea0aa86d53b".to_owned(), public_key) + .unwrap(); + let receipt = br#"{"payload":"eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3QucmVsYXlSZWNlaXB0LzEiLCJwcm90b2NvbFZlcnNpb24iOiJ2MSIsInRyYW5zZmVyVWlkIjoiMDE5OGYzYTEtYTIwMC03YjIwLThiMjItMTEyMjMzNDQ1NTY2IiwibWF0ZXJpYWxLaW5kIjoiRElBR05PU1RJQ19CVU5ETEVfTUFOSUZFU1QiLCJkaXJlY3Rpb24iOiJDTFVTVEVSX1RPX0NPTk5FQ1QiLCJhcnRpZmFjdFNoYTI1NiI6IjNlMTZjODQwMTY3ZjdlYTMzNDRlOWQyY2JkMzZiMmUzZjc4ZmM1MDViYTE3ZmVkMzM2OGY4OGZkYzcyYjVjN2UiLCJwcm9kdWNlciI6eyJ0eXBlIjoiREVWSUNFIiwibmFtZSI6Im9yZ2FuaXphdGlvbnMvMDE5OGYzYTEtNGMwMC03YTEwLThiMjEtMGMxZDJlM2Y0YTUwL2NsdXN0ZXJzLzAxOThmM2ExLTVkMDAtN2IyMC05YzMxLTFkMmUzZjRhNWI2MS9jbHVzdGVyRGV2aWNlcy8wMTk4ZjNhMS02ZTAwLTdjMzAtYWQ0MS0yZTNmNGE1YjZjNzIiLCJrZXlJZCI6IjM5Y2EyNGM4YjAyYTU1OWZkOWJlYjJiMWY1ZDE4Y2VkMjBjNGJiMjQ2NTc3YjkyOTE0YWU2ODE0YzNmNzBhY2YifSwiZGVzdGluYXRpb24iOnsidHlwZSI6IkNPTk5FQ1QiLCJuYW1lIjoib3JnYW5pemF0aW9ucy8wMTk4ZjNhMS00YzAwLTdhMTAtOGIyMS0wYzFkMmUzZjRhNTAifSwib3V0Y29tZSI6IkFQUExJRUQiLCJyZWNlaXZlZEF0IjoiMjAyNi0wOC0yMFQwOTozMDowMVoifQ","signature":{"algorithm":"Ed25519","keyId":"aef7765496addd64bb9fcdd7b61682148622aed4856a7315326faea0aa86d53b","value":"wA8ruum_X9g2Z3ZPdyimS5mbPclsWc7YD-7ZQrN2pNrcrJwBmwhOLIAHmcPevZ__RC-sJcIPixOa0n9B3ygYDA"}}"#; + + let verified = relay::verify_receipt(receipt, &envelope, &trust).unwrap(); + assert_eq!(verified.outcome, RelayReceiptOutcome::Applied); +} + +fn envelope(bytes: &[u8], producer: RelayParty, destination: RelayParty) -> RelayEnvelope { + RelayEnvelope { + format_version: relay::RELAY_ENVELOPE_FORMAT.to_owned(), + protocol_version: "v1".to_owned(), + transfer_uid: TRANSFER_UID.to_owned(), + material_kind: RelayMaterialKind::DiagnosticBundleManifest, + direction: RelayDirection::ClusterToConnect, + artifact: relay::RelayArtifact { + encoding: "base64".to_owned(), + bytes: BASE64_STANDARD.encode_to_string(bytes), + sha256: hex_lower(&Sha256::digest(bytes)), + }, + asserted_producer: producer, + destination, + } +} + +fn hex_lower(bytes: &[u8]) -> String { + hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower) +}