From 58c8c6ea74ae459743b27e67c6f8d2de1ce9bb7a Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 19 Oct 2023 10:24:16 -0700 Subject: [PATCH] [wicketd] Accept TUF repos with RoT archives signed with different keys (#4289) As of this PR, wicketd will (a) accept TUF repos containing multiple RoT archives for the same board target (e.g., multiple gimlet RoT images), and when performing a mupdate, it will ask the RoT for its currently-active CMPA and CFPA pages and search for an RoT archive that matches. After this is deployed to all fielded systems, we'll be able to drop the `-rot-staging-dev` and `-prod-rel` TUF repos from CI, and only build a single TUF repo with all RoT images. This PR adds a new `-rot-all` TUF repo publishing step but does not remove the old ones, as we'll need them to update into this version of wicketd. --- .github/buildomat/jobs/tuf-repo.sh | 62 ++++++ Cargo.lock | 52 ++--- Cargo.toml | 4 +- gateway/Cargo.toml | 1 + gateway/src/http_entrypoints.rs | 137 +++++++++++++ openapi/gateway.json | 201 +++++++++++++++++++ sp-sim/src/gimlet.rs | 19 ++ sp-sim/src/sidecar.rs | 19 ++ wicket-common/src/update_events.rs | 15 ++ wicketd/Cargo.toml | 1 + wicketd/src/artifacts/extracted_artifacts.rs | 2 +- wicketd/src/artifacts/update_plan.rs | 132 ++++++------ wicketd/src/update_tracker.rs | 171 +++++++++++++--- wicketd/tests/integration_tests/updates.rs | 4 +- workspace-hack/Cargo.toml | 4 +- 15 files changed, 695 insertions(+), 129 deletions(-) diff --git a/.github/buildomat/jobs/tuf-repo.sh b/.github/buildomat/jobs/tuf-repo.sh index 57d3ba8a1f..ea25ab5834 100644 --- a/.github/buildomat/jobs/tuf-repo.sh +++ b/.github/buildomat/jobs/tuf-repo.sh @@ -25,6 +25,21 @@ #: job = "helios / build trampoline OS image" #: #: [[publish]] +#: series = "rot-all" +#: name = "repo.zip.parta" +#: from_output = "/work/repo-rot-all.zip.parta" +#: +#: [[publish]] +#: series = "rot-all" +#: name = "repo.zip.partb" +#: from_output = "/work/repo-rot-all.zip.partb" +#: +#: [[publish]] +#: series = "rot-all" +#: name = "repo.zip.sha256.txt" +#: from_output = "/work/repo-rot-all.zip.sha256.txt" +#: +#: [[publish]] #: series = "rot-prod-rel" #: name = "repo.zip.parta" #: from_output = "/work/repo-rot-prod-rel.zip.parta" @@ -168,6 +183,38 @@ caboose_util_rot() { } SERIES_LIST=() + +# Create an initial `manifest-rot-all.toml` containing the SP images for all +# boards. While we still need to build multiple TUF repos, +# `add_hubris_artifacts` below will append RoT images to this manifest (in +# addition to the single-RoT manifest it creates). +prep_rot_all_series() { + series="rot-all" + + SERIES_LIST+=("$series") + + manifest=/work/manifest-$series.toml + cp /work/manifest.toml "$manifest" + + for board_rev in "${ALL_BOARDS[@]}"; do + board=${board_rev%-?} + tufaceous_board=${board//sidecar/switch} + sp_image="/work/hubris/${board_rev}.zip" + sp_caboose_version=$(/work/caboose-util read-version "$sp_image") + sp_caboose_board=$(/work/caboose-util read-board "$sp_image") + + cat >>"$manifest" <>"$manifest_rot_all" <>, + path: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + + let PathSpComponent { sp, component } = path.into_inner(); + + // Ensure the caller knows they're asking for the RoT + if component_from_str(&component)? != SpComponent::ROT { + return Err(HttpError::for_bad_request( + Some("RequestUnsupportedForComponent".to_string()), + "Only the RoT has a CFPA".into(), + )); + } + + let sp = apictx.mgmt_switch.sp(sp.into())?; + let data = sp.read_rot_cmpa().await.map_err(SpCommsError::from)?; + + let base64_data = base64::engine::general_purpose::STANDARD.encode(data); + + Ok(HttpResponseOk(RotCmpa { base64_data })) +} + +/// Read the requested CFPA slot from a root of trust. +/// +/// This endpoint is only valid for the `rot` component. +#[endpoint { + method = GET, + path = "/sp/{type}/{slot}/component/{component}/cfpa", +}] +async fn sp_rot_cfpa_get( + rqctx: RequestContext>, + path: Path, + params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + + let PathSpComponent { sp, component } = path.into_inner(); + let GetCfpaParams { slot } = params.into_inner(); + + // Ensure the caller knows they're asking for the RoT + if component_from_str(&component)? != SpComponent::ROT { + return Err(HttpError::for_bad_request( + Some("RequestUnsupportedForComponent".to_string()), + "Only the RoT has a CFPA".into(), + )); + } + + let sp = apictx.mgmt_switch.sp(sp.into())?; + let data = match slot { + RotCfpaSlot::Active => sp.read_rot_active_cfpa().await, + RotCfpaSlot::Inactive => sp.read_rot_inactive_cfpa().await, + RotCfpaSlot::Scratch => sp.read_rot_scratch_cfpa().await, + } + .map_err(SpCommsError::from)?; + + let base64_data = base64::engine::general_purpose::STANDARD.encode(data); + + Ok(HttpResponseOk(RotCfpa { base64_data, slot })) +} + /// List SPs via Ignition /// /// Retreive information for all SPs via the Ignition controller. This is lower @@ -1324,6 +1459,8 @@ pub fn api() -> GatewayApiDescription { api.register(sp_component_update)?; api.register(sp_component_update_status)?; api.register(sp_component_update_abort)?; + api.register(sp_rot_cmpa_get)?; + api.register(sp_rot_cfpa_get)?; api.register(sp_host_phase2_progress_get)?; api.register(sp_host_phase2_progress_delete)?; api.register(ignition_list)?; diff --git a/openapi/gateway.json b/openapi/gateway.json index 847d1f746d..67cc2bd634 100644 --- a/openapi/gateway.json +++ b/openapi/gateway.json @@ -551,6 +551,70 @@ } } }, + "/sp/{type}/{slot}/component/{component}/cfpa": { + "get": { + "summary": "Read the requested CFPA slot from a root of trust.", + "description": "This endpoint is only valid for the `rot` component.", + "operationId": "sp_rot_cfpa_get", + "parameters": [ + { + "in": "path", + "name": "component", + "description": "ID for the component of the SP; this is the internal identifier used by the SP itself to identify its components.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "slot", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "$ref": "#/components/schemas/SpType" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCfpaParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotCfpa" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/sp/{type}/{slot}/component/{component}/clear-status": { "post": { "summary": "Clear status of a component", @@ -598,6 +662,60 @@ } } }, + "/sp/{type}/{slot}/component/{component}/cmpa": { + "get": { + "summary": "Read the CMPA from a root of trust.", + "description": "This endpoint is only valid for the `rot` component.", + "operationId": "sp_rot_cmpa_get", + "parameters": [ + { + "in": "path", + "name": "component", + "description": "ID for the component of the SP; this is the internal identifier used by the SP itself to identify its components.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "slot", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "$ref": "#/components/schemas/SpType" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotCmpa" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/sp/{type}/{slot}/component/{component}/reset": { "post": { "summary": "Reset an SP component (possibly the SP itself).", @@ -1326,6 +1444,17 @@ "request_id" ] }, + "GetCfpaParams": { + "type": "object", + "properties": { + "slot": { + "$ref": "#/components/schemas/RotCfpaSlot" + } + }, + "required": [ + "slot" + ] + }, "HostPhase2Progress": { "oneOf": [ { @@ -2071,6 +2200,78 @@ "A2" ] }, + "RotCfpa": { + "type": "object", + "properties": { + "base64_data": { + "type": "string" + }, + "slot": { + "$ref": "#/components/schemas/RotCfpaSlot" + } + }, + "required": [ + "base64_data", + "slot" + ] + }, + "RotCfpaSlot": { + "oneOf": [ + { + "type": "object", + "properties": { + "slot": { + "type": "string", + "enum": [ + "active" + ] + } + }, + "required": [ + "slot" + ] + }, + { + "type": "object", + "properties": { + "slot": { + "type": "string", + "enum": [ + "inactive" + ] + } + }, + "required": [ + "slot" + ] + }, + { + "type": "object", + "properties": { + "slot": { + "type": "string", + "enum": [ + "scratch" + ] + } + }, + "required": [ + "slot" + ] + } + ] + }, + "RotCmpa": { + "type": "object", + "properties": { + "base64_data": { + "type": "string" + } + }, + "required": [ + "base64_data" + ] + }, "RotSlot": { "oneOf": [ { diff --git a/sp-sim/src/gimlet.rs b/sp-sim/src/gimlet.rs index dad53f3848..d131696559 100644 --- a/sp-sim/src/gimlet.rs +++ b/sp-sim/src/gimlet.rs @@ -1282,6 +1282,25 @@ impl SpHandler for Handler { buf[..val.len()].copy_from_slice(val); Ok(val.len()) } + + fn read_sensor( + &mut self, + _request: gateway_messages::SensorRequest, + ) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } + + fn current_time(&mut self) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } + + fn read_rot( + &mut self, + _request: gateway_messages::RotRequest, + _buf: &mut [u8], + ) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } } enum UpdateState { diff --git a/sp-sim/src/sidecar.rs b/sp-sim/src/sidecar.rs index def2a79c0c..e56c610c9c 100644 --- a/sp-sim/src/sidecar.rs +++ b/sp-sim/src/sidecar.rs @@ -1001,6 +1001,25 @@ impl SpHandler for Handler { buf[..val.len()].copy_from_slice(val); Ok(val.len()) } + + fn read_sensor( + &mut self, + _request: gateway_messages::SensorRequest, + ) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } + + fn current_time(&mut self) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } + + fn read_rot( + &mut self, + _request: gateway_messages::RotRequest, + _buf: &mut [u8], + ) -> std::result::Result { + Err(SpError::RequestUnsupportedForSp) + } } struct FakeIgnition { diff --git a/wicket-common/src/update_events.rs b/wicket-common/src/update_events.rs index 3dd984d07f..ac840f83ad 100644 --- a/wicket-common/src/update_events.rs +++ b/wicket-common/src/update_events.rs @@ -159,6 +159,21 @@ pub enum UpdateTerminalError { #[source] error: gateway_client::Error, }, + #[error("getting RoT CMPA failed")] + GetRotCmpaFailed { + #[source] + error: anyhow::Error, + }, + #[error("getting RoT CFPA failed")] + GetRotCfpaFailed { + #[source] + error: anyhow::Error, + }, + #[error("failed to find correctly-singed RoT image")] + FailedFindingSignedRotImage { + #[source] + error: anyhow::Error, + }, #[error("getting SP caboose failed")] GetSpCabooseFailed { #[source] diff --git a/wicketd/Cargo.toml b/wicketd/Cargo.toml index cf04b7c6a7..f11fda9750 100644 --- a/wicketd/Cargo.toml +++ b/wicketd/Cargo.toml @@ -7,6 +7,7 @@ license = "MPL-2.0" [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true bytes.workspace = true camino.workspace = true camino-tempfile.workspace = true diff --git a/wicketd/src/artifacts/extracted_artifacts.rs b/wicketd/src/artifacts/extracted_artifacts.rs index f9ad59404b..352d8ad3d5 100644 --- a/wicketd/src/artifacts/extracted_artifacts.rs +++ b/wicketd/src/artifacts/extracted_artifacts.rs @@ -61,7 +61,7 @@ impl Eq for ExtractedArtifactDataHandle {} impl ExtractedArtifactDataHandle { /// File size of this artifact in bytes. - pub(super) fn file_size(&self) -> usize { + pub(crate) fn file_size(&self) -> usize { self.file_size } diff --git a/wicketd/src/artifacts/update_plan.rs b/wicketd/src/artifacts/update_plan.rs index 2668aaac51..31a8a06ca2 100644 --- a/wicketd/src/artifacts/update_plan.rs +++ b/wicketd/src/artifacts/update_plan.rs @@ -39,14 +39,14 @@ use tufaceous_lib::RotArchives; pub struct UpdatePlan { pub(crate) system_version: SemverVersion, pub(crate) gimlet_sp: BTreeMap, - pub(crate) gimlet_rot_a: ArtifactIdData, - pub(crate) gimlet_rot_b: ArtifactIdData, + pub(crate) gimlet_rot_a: Vec, + pub(crate) gimlet_rot_b: Vec, pub(crate) psc_sp: BTreeMap, - pub(crate) psc_rot_a: ArtifactIdData, - pub(crate) psc_rot_b: ArtifactIdData, + pub(crate) psc_rot_a: Vec, + pub(crate) psc_rot_b: Vec, pub(crate) sidecar_sp: BTreeMap, - pub(crate) sidecar_rot_a: ArtifactIdData, - pub(crate) sidecar_rot_b: ArtifactIdData, + pub(crate) sidecar_rot_a: Vec, + pub(crate) sidecar_rot_b: Vec, // Note: The Trampoline image is broken into phase1/phase2 as part of our // update plan (because they go to different destinations), but the two @@ -83,14 +83,14 @@ pub(super) struct UpdatePlanBuilder<'a> { // fields that mirror `UpdatePlan` system_version: SemverVersion, gimlet_sp: BTreeMap, - gimlet_rot_a: Option, - gimlet_rot_b: Option, + gimlet_rot_a: Vec, + gimlet_rot_b: Vec, psc_sp: BTreeMap, - psc_rot_a: Option, - psc_rot_b: Option, + psc_rot_a: Vec, + psc_rot_b: Vec, sidecar_sp: BTreeMap, - sidecar_rot_a: Option, - sidecar_rot_b: Option, + sidecar_rot_a: Vec, + sidecar_rot_b: Vec, // We always send phase 1 images (regardless of host or trampoline) to the // SP via MGS, so we retain their data. @@ -124,14 +124,14 @@ impl<'a> UpdatePlanBuilder<'a> { Ok(Self { system_version, gimlet_sp: BTreeMap::new(), - gimlet_rot_a: None, - gimlet_rot_b: None, + gimlet_rot_a: Vec::new(), + gimlet_rot_b: Vec::new(), psc_sp: BTreeMap::new(), - psc_rot_a: None, - psc_rot_b: None, + psc_rot_a: Vec::new(), + psc_rot_b: Vec::new(), sidecar_sp: BTreeMap::new(), - sidecar_rot_a: None, - sidecar_rot_b: None, + sidecar_rot_a: Vec::new(), + sidecar_rot_b: Vec::new(), host_phase_1: None, trampoline_phase_1: None, trampoline_phase_2: None, @@ -309,10 +309,6 @@ impl<'a> UpdatePlanBuilder<'a> { | KnownArtifactKind::SwitchSp => unreachable!(), }; - if rot_a.is_some() || rot_b.is_some() { - return Err(RepositoryError::DuplicateArtifactKind(artifact_kind)); - } - let (rot_a_data, rot_b_data) = Self::extract_nested_artifact_pair( &mut self.extracted_artifacts, artifact_kind, @@ -336,10 +332,8 @@ impl<'a> UpdatePlanBuilder<'a> { kind: rot_b_kind.clone(), }; - *rot_a = - Some(ArtifactIdData { id: rot_a_id, data: rot_a_data.clone() }); - *rot_b = - Some(ArtifactIdData { id: rot_b_id, data: rot_b_data.clone() }); + rot_a.push(ArtifactIdData { id: rot_a_id, data: rot_a_data.clone() }); + rot_b.push(ArtifactIdData { id: rot_b_id, data: rot_b_data.clone() }); record_extracted_artifact( artifact_id.clone(), @@ -574,53 +568,39 @@ impl<'a> UpdatePlanBuilder<'a> { pub(super) fn build(self) -> Result { // Ensure our multi-board-supporting kinds have at least one board // present. - if self.gimlet_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletSp, - )); - } - if self.psc_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::PscSp, - )); - } - if self.sidecar_sp.is_empty() { - return Err(RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchSp, - )); + for (kind, no_artifacts) in [ + (KnownArtifactKind::GimletSp, self.gimlet_sp.is_empty()), + (KnownArtifactKind::PscSp, self.psc_sp.is_empty()), + (KnownArtifactKind::SwitchSp, self.sidecar_sp.is_empty()), + ( + KnownArtifactKind::GimletRot, + self.gimlet_rot_a.is_empty() || self.gimlet_rot_b.is_empty(), + ), + ( + KnownArtifactKind::PscRot, + self.psc_rot_a.is_empty() || self.psc_rot_b.is_empty(), + ), + ( + KnownArtifactKind::SwitchRot, + self.sidecar_rot_a.is_empty() || self.sidecar_rot_b.is_empty(), + ), + ] { + if no_artifacts { + return Err(RepositoryError::MissingArtifactKind(kind)); + } } Ok(UpdatePlan { system_version: self.system_version, gimlet_sp: self.gimlet_sp, // checked above - gimlet_rot_a: self.gimlet_rot_a.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletRot, - ), - )?, - gimlet_rot_b: self.gimlet_rot_b.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::GimletRot, - ), - )?, - psc_sp: self.psc_sp, // checked above - psc_rot_a: self.psc_rot_a.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), - )?, - psc_rot_b: self.psc_rot_b.ok_or( - RepositoryError::MissingArtifactKind(KnownArtifactKind::PscRot), - )?, + gimlet_rot_a: self.gimlet_rot_a, // checked above + gimlet_rot_b: self.gimlet_rot_b, // checked above + psc_sp: self.psc_sp, // checked above + psc_rot_a: self.psc_rot_a, // checked above + psc_rot_b: self.psc_rot_b, // checked above sidecar_sp: self.sidecar_sp, // checked above - sidecar_rot_a: self.sidecar_rot_a.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchRot, - ), - )?, - sidecar_rot_b: self.sidecar_rot_b.ok_or( - RepositoryError::MissingArtifactKind( - KnownArtifactKind::SwitchRot, - ), - )?, + sidecar_rot_a: self.sidecar_rot_a, // checked above + sidecar_rot_b: self.sidecar_rot_b, // checked above host_phase_1: self.host_phase_1.ok_or( RepositoryError::MissingArtifactKind(KnownArtifactKind::Host), )?, @@ -1030,21 +1010,27 @@ mod tests { // Check extracted RoT data assert_eq!( - read_to_vec(&plan.gimlet_rot_a.data).await, + read_to_vec(&plan.gimlet_rot_a[0].data).await, gimlet_rot.archive_a ); assert_eq!( - read_to_vec(&plan.gimlet_rot_b.data).await, + read_to_vec(&plan.gimlet_rot_b[0].data).await, gimlet_rot.archive_b ); - assert_eq!(read_to_vec(&plan.psc_rot_a.data).await, psc_rot.archive_a); - assert_eq!(read_to_vec(&plan.psc_rot_b.data).await, psc_rot.archive_b); assert_eq!( - read_to_vec(&plan.sidecar_rot_a.data).await, + read_to_vec(&plan.psc_rot_a[0].data).await, + psc_rot.archive_a + ); + assert_eq!( + read_to_vec(&plan.psc_rot_b[0].data).await, + psc_rot.archive_b + ); + assert_eq!( + read_to_vec(&plan.sidecar_rot_a[0].data).await, sidecar_rot.archive_a ); assert_eq!( - read_to_vec(&plan.sidecar_rot_b.data).await, + read_to_vec(&plan.sidecar_rot_b[0].data).await, sidecar_rot.archive_b ); diff --git a/wicketd/src/update_tracker.rs b/wicketd/src/update_tracker.rs index 1bbda00158..e968d65a30 100644 --- a/wicketd/src/update_tracker.rs +++ b/wicketd/src/update_tracker.rs @@ -18,18 +18,23 @@ use anyhow::anyhow; use anyhow::bail; use anyhow::ensure; use anyhow::Context; +use base64::Engine; use display_error_chain::DisplayErrorChain; use dropshot::HttpError; +use futures::TryFutureExt; use gateway_client::types::HostPhase2Progress; use gateway_client::types::HostPhase2RecoveryImageId; use gateway_client::types::HostStartupOptions; use gateway_client::types::InstallinatorImageId; use gateway_client::types::PowerState; +use gateway_client::types::RotCfpaSlot; use gateway_client::types::SpComponentFirmwareSlot; use gateway_client::types::SpIdentifier; use gateway_client::types::SpType; use gateway_client::types::SpUpdateStatus; use gateway_messages::SpComponent; +use gateway_messages::ROT_PAGE_SIZE; +use hubtools::RawHubrisArchive; use installinator_common::InstallinatorCompletionMetadata; use installinator_common::InstallinatorSpec; use installinator_common::M2Slot; @@ -52,11 +57,13 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; use thiserror::Error; +use tokio::io::AsyncReadExt; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use tokio_util::io::StreamReader; use update_engine::events::ProgressUnits; use update_engine::AbortHandle; use update_engine::StepSpec; @@ -768,19 +775,13 @@ impl UpdateDriver { } let (rot_a, rot_b, sp_artifacts) = match update_cx.sp.type_ { - SpType::Sled => ( - plan.gimlet_rot_a.clone(), - plan.gimlet_rot_b.clone(), - &plan.gimlet_sp, - ), - SpType::Power => { - (plan.psc_rot_a.clone(), plan.psc_rot_b.clone(), &plan.psc_sp) + SpType::Sled => { + (&plan.gimlet_rot_a, &plan.gimlet_rot_b, &plan.gimlet_sp) + } + SpType::Power => (&plan.psc_rot_a, &plan.psc_rot_b, &plan.psc_sp), + SpType::Switch => { + (&plan.sidecar_rot_a, &plan.sidecar_rot_b, &plan.sidecar_sp) } - SpType::Switch => ( - plan.sidecar_rot_a.clone(), - plan.sidecar_rot_b.clone(), - &plan.sidecar_sp, - ), }; let rot_registrar = engine.for_component(UpdateComponent::Rot); @@ -790,16 +791,15 @@ impl UpdateDriver { // currently executing; we must update the _other_ slot. We also want to // know its current version (so we can skip updating if we only need to // update the SP and/or host). - let rot_interrogation = - rot_registrar - .new_step( - UpdateStepId::InterrogateRot, - "Checking current RoT version and active slot", - |_cx| async move { - update_cx.interrogate_rot(rot_a, rot_b).await - }, - ) - .register(); + let rot_interrogation = rot_registrar + .new_step( + UpdateStepId::InterrogateRot, + "Checking current RoT version and active slot", + move |_cx| async move { + update_cx.interrogate_rot(rot_a, rot_b).await + }, + ) + .register(); // The SP only has one updateable firmware slot ("the inactive bank"). // We want to ask about slot 0 (the active slot)'s current version, and @@ -1557,8 +1557,8 @@ impl UpdateContext { async fn interrogate_rot( &self, - rot_a: ArtifactIdData, - rot_b: ArtifactIdData, + rot_a: &[ArtifactIdData], + rot_b: &[ArtifactIdData], ) -> Result, UpdateTerminalError> { let rot_active_slot = self .get_component_active_slot(SpComponent::ROT.const_as_str()) @@ -1569,7 +1569,7 @@ impl UpdateContext { // Flip these around: if 0 (A) is active, we want to // update 1 (B), and vice versa. - let (active_slot_name, slot_to_update, artifact_to_apply) = + let (active_slot_name, slot_to_update, available_artifacts) = match rot_active_slot { 0 => ('A', 1, rot_b), 1 => ('B', 0, rot_a), @@ -1582,6 +1582,127 @@ impl UpdateContext { } }; + // Read the CMPA and currently-active CFPA so we can find the + // correctly-signed artifact. + let base64_decode_rot_page = |data: String| { + // Even though we know `data` should decode to exactly + // `ROT_PAGE_SIZE` bytes, the base64 crate requires an output buffer + // of at least `decoded_len_estimate`. Allocate such a buffer here, + // then we'll copy to the fixed-size array we need after confirming + // the number of decoded bytes; + let mut output_buf = + vec![0; base64::decoded_len_estimate(data.len())]; + + let n = base64::engine::general_purpose::STANDARD + .decode_slice(&data, &mut output_buf) + .with_context(|| { + format!("failed to decode base64 string: {data:?}") + })?; + if n != ROT_PAGE_SIZE { + bail!( + "incorrect len ({n}, expected {ROT_PAGE_SIZE}) \ + after decoding base64 string: {data:?}", + ); + } + let mut page = [0; ROT_PAGE_SIZE]; + page.copy_from_slice(&output_buf[..n]); + Ok(page) + }; + let cmpa = self + .mgs_client + .sp_rot_cmpa_get( + self.sp.type_, + self.sp.slot, + SpComponent::ROT.const_as_str(), + ) + .await + .map_err(|err| UpdateTerminalError::GetRotCmpaFailed { + error: err.into(), + }) + .and_then(|response| { + let data = response.into_inner().base64_data; + base64_decode_rot_page(data).map_err(|error| { + UpdateTerminalError::GetRotCmpaFailed { error } + }) + })?; + let cfpa = self + .mgs_client + .sp_rot_cfpa_get( + self.sp.type_, + self.sp.slot, + SpComponent::ROT.const_as_str(), + &gateway_client::types::GetCfpaParams { + slot: RotCfpaSlot::Active, + }, + ) + .await + .map_err(|err| UpdateTerminalError::GetRotCfpaFailed { + error: err.into(), + }) + .and_then(|response| { + let data = response.into_inner().base64_data; + base64_decode_rot_page(data).map_err(|error| { + UpdateTerminalError::GetRotCfpaFailed { error } + }) + })?; + + // Loop through our possible artifacts and find the first (we only + // expect one!) that verifies against the RoT's CMPA/CFPA. + let mut artifact_to_apply = None; + for artifact in available_artifacts { + let image = artifact + .data + .reader_stream() + .and_then(|stream| async { + let mut buf = Vec::with_capacity(artifact.data.file_size()); + StreamReader::new(stream) + .read_to_end(&mut buf) + .await + .context("I/O error reading extracted archive")?; + Ok(buf) + }) + .await + .map_err(|error| { + UpdateTerminalError::FailedFindingSignedRotImage { error } + })?; + let archive = RawHubrisArchive::from_vec(image).map_err(|err| { + UpdateTerminalError::FailedFindingSignedRotImage { + error: anyhow::Error::new(err).context(format!( + "failed to read hubris archive for {:?}", + artifact.id + )), + } + })?; + match archive.verify(&cmpa, &cfpa) { + Ok(()) => { + info!( + self.log, "RoT archive verification success"; + "name" => artifact.id.name.as_str(), + "version" => %artifact.id.version, + "kind" => ?artifact.id.kind, + ); + artifact_to_apply = Some(artifact.clone()); + break; + } + Err(err) => { + // We log this but don't fail - we want to continue looking + // for a verifiable artifact. + info!( + self.log, "RoT archive verification failed"; + "artifact" => ?artifact.id, + "err" => %DisplayErrorChain::new(&err), + ); + } + } + } + + // Ensure we found a valid RoT artifact. + let artifact_to_apply = artifact_to_apply.ok_or_else(|| { + UpdateTerminalError::FailedFindingSignedRotImage { + error: anyhow!("no RoT image found with valid CMPA/CFPA"), + } + })?; + // Read the caboose of the currently-active slot. let caboose = self .mgs_client diff --git a/wicketd/tests/integration_tests/updates.rs b/wicketd/tests/integration_tests/updates.rs index a198068ef3..af743190c2 100644 --- a/wicketd/tests/integration_tests/updates.rs +++ b/wicketd/tests/integration_tests/updates.rs @@ -169,8 +169,8 @@ async fn test_updates() { StepEventKind::ExecutionFailed { failed_step, .. } => { // TODO: obviously we shouldn't stop here, get past more of the // update process in this test. We currently fail when attempting to - // look up the SP's board in our tuf repo. - assert_eq!(failed_step.info.component, UpdateComponent::Sp); + // look up the RoT's CMPA/CFPA. + assert_eq!(failed_step.info.component, UpdateComponent::Rot); } other => { panic!("unexpected terminal event kind: {other:?}"); diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 36c3fe3f5f..b08f2612f1 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -43,7 +43,7 @@ futures-io = { version = "0.3.28", default-features = false, features = ["std"] futures-sink = { version = "0.3.28" } futures-task = { version = "0.3.28", default-features = false, features = ["std"] } futures-util = { version = "0.3.28", features = ["channel", "io", "sink"] } -gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway-service", rev = "1e180ae55e56bd17af35cb868ffbd18ce487351d", features = ["std"] } +gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway-service", rev = "2739c18e80697aa6bc235c935176d14b4d757ee9", features = ["std"] } generic-array = { version = "0.14.7", default-features = false, features = ["more_lengths", "zeroize"] } getrandom = { version = "0.2.10", default-features = false, features = ["js", "rdrand", "std"] } hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.0", features = ["raw"] } @@ -135,7 +135,7 @@ futures-io = { version = "0.3.28", default-features = false, features = ["std"] futures-sink = { version = "0.3.28" } futures-task = { version = "0.3.28", default-features = false, features = ["std"] } futures-util = { version = "0.3.28", features = ["channel", "io", "sink"] } -gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway-service", rev = "1e180ae55e56bd17af35cb868ffbd18ce487351d", features = ["std"] } +gateway-messages = { git = "https://github.com/oxidecomputer/management-gateway-service", rev = "2739c18e80697aa6bc235c935176d14b4d757ee9", features = ["std"] } generic-array = { version = "0.14.7", default-features = false, features = ["more_lengths", "zeroize"] } getrandom = { version = "0.2.10", default-features = false, features = ["js", "rdrand", "std"] } hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14.0", features = ["raw"] }