feat(connect): add approved artifact relay (#7772)

feat(connect): add approved artifact relay core
This commit is contained in:
Chris
2026-09-14 02:33:12 +08:00
committed by GitHub
parent ca20e06405
commit 2ac1a70e68
6 changed files with 1089 additions and 4 deletions
+82 -2
View File
@@ -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<ConnectRelayOpts>),
/// 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<ConnectRelayOpts>),
/// 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"] {
+1
View File
@@ -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)),
},
+6
View File
@@ -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};
+623
View File
@@ -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<String>,
}
#[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<Self, RelayError> {
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<Self, RelayError> {
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<Option<Vec<u8>>, ()>;
}
#[derive(Debug)]
pub struct RelayDelivery {
pub review: RelayReview,
pub envelope: RelayEnvelope,
pub receipt: RelayReceiptPayload,
pub receipt_bytes: Vec<u8>,
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<Self, RelayError> {
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::<Result<Vec<_>, _>>()
.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<RelayDelivery, RelayError> {
#[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<T, A>(
transfer_uid: &str,
material_kind: RelayMaterialKind,
artifact_bytes: &[u8],
asserted_producer: RelayParty,
destination: RelayParty,
trusted_receipt_signer: &TrustedReceiptSigner,
approve: A,
transport: &mut T,
) -> Result<RelayDelivery, RelayError>
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<A>(
transfer_uid: &str,
material_kind: RelayMaterialKind,
artifact_bytes: &[u8],
asserted_producer: RelayParty,
destination: RelayParty,
approve: A,
) -> Result<PreparedRelay, RelayError>
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<RelayReceiptPayload, RelayError> {
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<Vec<u8>, 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<Vec<u8>, RelayError> {
read_protected_bytes(path, MAX_RELAY_ARTIFACT_BYTES as u64)
}
pub fn read_protected_relay_authentication(path: &Path) -> Result<String, RelayError> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>> {
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)
}
+77 -2
View File
@@ -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<Vec<u8>> {
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<crate::connect::LicenseVerificationContext, crate::connect::LicenseArtifactError> {
+300
View File
@@ -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<String, RelayReplayKey>,
applied: HashMap<RelayReplayKey, String>,
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<u8> {
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<Option<Vec<u8>>, ()> {
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::<RelayEnvelope>(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)
}