mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-18 16:45:11 +00:00
feat(connect): add approved artifact relay (#7772)
feat(connect): add approved artifact relay core
This commit is contained in:
@@ -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"] {
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user