Skip to content

Commit 3923a43

Browse files
authored
repository load methods with trusted roots (#30)
Part of oxidecomputer/omicron#3578. The current plan is to put our trust store in CockroachDB, which allows us to have multiple trust roots uploaded, so we loop through the roots in the trust store and try to load the repository metadata with each one. If we fail to verify any of the metadata, we try the next root in the provided trust store.
1 parent d2cf832 commit 3923a43

File tree

5 files changed

+202
-22
lines changed

5 files changed

+202
-22
lines changed

bin/src/dispatch.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ impl Args {
4444
match self.command {
4545
Command::Init { system_version, no_generate_key } => {
4646
let keys = maybe_generate_keys(self.keys, no_generate_key)?;
47+
let root =
48+
tufaceous_lib::root::new_root(keys.clone(), self.expiry)
49+
.await?;
4750

4851
let repo = OmicronRepo::initialize(
4952
log,
5053
&repo_path,
5154
system_version,
5255
keys,
56+
root,
5357
self.expiry,
5458
)
5559
.await?;

lib/src/assemble/build.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use anyhow::{Context, Result};
66
use camino::{Utf8Path, Utf8PathBuf};
77
use chrono::{DateTime, Utc};
8+
use tough::editor::signed::SignedRole;
9+
use tough::schema::Root;
810

911
use crate::{AddArtifact, Key, OmicronRepo, utils::merge_anyhow_list};
1012

@@ -17,6 +19,7 @@ pub struct OmicronRepoAssembler {
1719
manifest: ArtifactManifest,
1820
build_dir: Option<Utf8PathBuf>,
1921
keys: Vec<Key>,
22+
root: Option<SignedRole<Root>>,
2023
expiry: DateTime<Utc>,
2124
output_path: Utf8PathBuf,
2225
}
@@ -34,6 +37,7 @@ impl OmicronRepoAssembler {
3437
manifest,
3538
build_dir: None,
3639
keys,
40+
root: None,
3741
expiry,
3842
output_path,
3943
}
@@ -44,6 +48,11 @@ impl OmicronRepoAssembler {
4448
self
4549
}
4650

51+
pub fn set_root_role(&mut self, root_role: SignedRole<Root>) -> &mut Self {
52+
self.root = Some(root_role);
53+
self
54+
}
55+
4756
pub async fn build(&self) -> Result<()> {
4857
let (build_dir, is_temp) = match &self.build_dir {
4958
Some(dir) => (dir.clone(), false),
@@ -92,11 +101,18 @@ impl OmicronRepoAssembler {
92101
}
93102

94103
async fn build_impl(&self, build_dir: &Utf8Path) -> Result<()> {
104+
let root = match &self.root {
105+
Some(root) => root.clone(),
106+
None => {
107+
crate::root::new_root(self.keys.clone(), self.expiry).await?
108+
}
109+
};
95110
let mut repository = OmicronRepo::initialize(
96111
&self.log,
97112
build_dir,
98113
self.manifest.system_version.clone(),
99114
self.keys.clone(),
115+
root,
100116
self.expiry,
101117
)
102118
.await?

lib/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ mod artifact;
77
pub mod assemble;
88
mod key;
99
mod repository;
10-
mod root;
10+
pub mod root;
1111
mod target;
1212
mod utils;
1313

lib/src/repository.rs

Lines changed: 180 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use futures::TryStreamExt;
1414
use semver::Version;
1515
use tough::editor::RepositoryEditor;
1616
use tough::editor::signed::SignedRole;
17+
use tough::error::Error;
1718
use tough::schema::{Root, Target};
1819
use tough::{ExpirationEnforcement, Repository, RepositoryLoader, TargetName};
1920
use tufaceous_artifact::{
@@ -31,6 +32,7 @@ use crate::utils::merge_anyhow_list;
3132
use crate::{AddArtifact, ArchiveBuilder};
3233

3334
/// A TUF repository describing Omicron.
35+
#[derive(Debug)]
3436
pub struct OmicronRepo {
3537
log: slog::Logger,
3638
repo: Repository,
@@ -44,9 +46,9 @@ impl OmicronRepo {
4446
repo_path: &Utf8Path,
4547
system_version: Version,
4648
keys: Vec<Key>,
49+
root: SignedRole<Root>,
4750
expiry: DateTime<Utc>,
4851
) -> Result<Self> {
49-
let root = crate::root::new_root(keys.clone(), expiry).await?;
5052
let editor = OmicronRepoEditor::initialize(
5153
repo_path.to_owned(),
5254
root,
@@ -64,6 +66,86 @@ impl OmicronRepo {
6466
Self::load_untrusted(log, repo_path).await
6567
}
6668

69+
/// Loads a repository from the given path.
70+
///
71+
/// This method enforces expirations. To load without expiration enforcement, use
72+
/// [`Self::load_ignore_expiration`].
73+
pub async fn load(
74+
log: &slog::Logger,
75+
repo_path: &Utf8Path,
76+
trusted_roots: impl IntoIterator<Item = impl AsRef<[u8]>>,
77+
) -> Result<Self> {
78+
Self::load_impl(
79+
log,
80+
repo_path,
81+
trusted_roots,
82+
ExpirationEnforcement::Safe,
83+
)
84+
.await
85+
}
86+
87+
/// Loads a repository from the given path, ignoring expiration.
88+
///
89+
/// Use cases for this include:
90+
///
91+
/// 1. When you're editing an existing repository and will re-sign it afterwards.
92+
/// 2. When you're reading a repository that was uploaded out-of-band,
93+
/// instead of fetched from a network-accessible repository
94+
/// 3. In an environment in which time isn't available.
95+
pub async fn load_ignore_expiration(
96+
log: &slog::Logger,
97+
repo_path: &Utf8Path,
98+
trusted_roots: impl IntoIterator<Item = impl AsRef<[u8]>>,
99+
) -> Result<Self> {
100+
Self::load_impl(
101+
log,
102+
repo_path,
103+
trusted_roots,
104+
ExpirationEnforcement::Unsafe,
105+
)
106+
.await
107+
}
108+
109+
async fn load_impl(
110+
log: &slog::Logger,
111+
repo_path: &Utf8Path,
112+
trusted_roots: impl IntoIterator<Item = impl AsRef<[u8]>>,
113+
exp: ExpirationEnforcement,
114+
) -> Result<Self> {
115+
let repo_path = repo_path.canonicalize_utf8()?;
116+
let mut verify_error = None;
117+
for root in trusted_roots {
118+
match RepositoryLoader::new(
119+
&root,
120+
Url::from_file_path(repo_path.join("metadata"))
121+
.expect("the canonical path is not absolute?"),
122+
Url::from_file_path(repo_path.join("targets"))
123+
.expect("the canonical path is not absolute?"),
124+
)
125+
.expiration_enforcement(exp)
126+
.load()
127+
.await
128+
{
129+
Ok(repo) => {
130+
return Ok(Self {
131+
log: log.new(slog::o!("component" => "OmicronRepo")),
132+
repo,
133+
repo_path,
134+
});
135+
}
136+
Err(
137+
err @ (Error::VerifyMetadata { .. }
138+
| Error::VerifyTrustedMetadata { .. }),
139+
) if verify_error.is_none() => {
140+
verify_error = Some(err.into());
141+
continue;
142+
}
143+
Err(err) => return Err(err.into()),
144+
}
145+
}
146+
Err(verify_error.unwrap_or_else(|| anyhow!("trust store is empty")))
147+
}
148+
67149
/// Loads a repository from the given path.
68150
///
69151
/// This method enforces expirations. To load without expiration enforcement, use
@@ -81,7 +163,9 @@ impl OmicronRepo {
81163
/// Use cases for this include:
82164
///
83165
/// 1. When you're editing an existing repository and will re-sign it afterwards.
84-
/// 2. In an environment in which time isn't available.
166+
/// 2. When you're reading a repository that was uploaded out-of-band,
167+
/// instead of fetched from a network-accessible repository
168+
/// 3. In an environment in which time isn't available.
85169
pub async fn load_untrusted_ignore_expiration(
86170
log: &slog::Logger,
87171
repo_path: &Utf8Path,
@@ -95,25 +179,12 @@ impl OmicronRepo {
95179
repo_path: &Utf8Path,
96180
exp: ExpirationEnforcement,
97181
) -> Result<Self> {
98-
let log = log.new(slog::o!("component" => "OmicronRepo"));
99182
let repo_path = repo_path.canonicalize_utf8()?;
100183
let root_json = repo_path.join("metadata").join("1.root.json");
101184
let root = tokio::fs::read(&root_json)
102185
.await
103186
.with_context(|| format!("error reading from {root_json}"))?;
104-
105-
let repo = RepositoryLoader::new(
106-
&root,
107-
Url::from_file_path(repo_path.join("metadata"))
108-
.expect("the canonical path is not absolute?"),
109-
Url::from_file_path(repo_path.join("targets"))
110-
.expect("the canonical path is not absolute?"),
111-
)
112-
.expiration_enforcement(exp)
113-
.load()
114-
.await?;
115-
116-
Ok(Self { log, repo, repo_path })
187+
Self::load_impl(log, &repo_path, &[root], exp).await
117188
}
118189

119190
/// Returns a canonicalized form of the repository path.
@@ -503,11 +574,96 @@ mod tests {
503574
use dropshot::test_util::LogContext;
504575
use dropshot::{ConfigLogging, ConfigLoggingIfExists, ConfigLoggingLevel};
505576

506-
use crate::ArtifactSource;
507-
use crate::assemble::ArtifactDeploymentUnits;
577+
use crate::assemble::{
578+
ArtifactDeploymentUnits, ArtifactManifest, OmicronRepoAssembler,
579+
};
580+
use crate::{ArchiveExtractor, ArtifactSource};
508581

509582
use super::*;
510583

584+
#[tokio::test]
585+
async fn load_trusted() {
586+
let log_config = ConfigLogging::File {
587+
level: ConfigLoggingLevel::Trace,
588+
path: "UNUSED".into(),
589+
if_exists: ConfigLoggingIfExists::Fail,
590+
};
591+
let logctx = LogContext::new(
592+
"reject_artifacts_with_the_same_filename",
593+
&log_config,
594+
);
595+
596+
// Generate a "trusted" root and an "untrusted" root.
597+
let expiry = Utc::now() + Days::new(1);
598+
let trusted_key = Key::generate_ed25519().unwrap();
599+
let trusted_root =
600+
crate::root::new_root(vec![trusted_key.clone()], expiry)
601+
.await
602+
.unwrap();
603+
let untrusted_key = Key::generate_ed25519().unwrap();
604+
let untrusted_root =
605+
crate::root::new_root(vec![untrusted_key], expiry).await.unwrap();
606+
607+
// Generate a repository using the trusted root.
608+
let tempdir = Utf8TempDir::new().unwrap();
609+
let archive_path = tempdir.path().join("repo.zip");
610+
let mut assembler = OmicronRepoAssembler::new(
611+
&logctx.log,
612+
ArtifactManifest::new_fake(),
613+
vec![trusted_key],
614+
expiry,
615+
archive_path.clone(),
616+
);
617+
assembler.set_root_role(trusted_root.clone());
618+
assembler.build().await.unwrap();
619+
// And now that we've created an archive and cleaned up the build
620+
// directory, immediately unarchive it... this is a bit silly, huh?
621+
let repo_dir = tempdir.path().join("repo");
622+
ArchiveExtractor::from_path(&archive_path)
623+
.unwrap()
624+
.extract(&repo_dir)
625+
.unwrap();
626+
627+
// If the trust store contains the root we generated the repo from, we
628+
// should successfully load it.
629+
for trust_store in [
630+
vec![trusted_root.buffer()],
631+
vec![trusted_root.buffer(), untrusted_root.buffer()],
632+
vec![untrusted_root.buffer(), trusted_root.buffer()],
633+
vec![trusted_root.buffer(), trusted_root.buffer()],
634+
] {
635+
OmicronRepo::load(&logctx.log, &repo_dir, trust_store)
636+
.await
637+
.unwrap();
638+
}
639+
// If the trust store is empty, we should fail.
640+
assert_eq!(
641+
OmicronRepo::load(&logctx.log, &repo_dir, [] as [Vec<u8>; 0])
642+
.await
643+
.unwrap_err()
644+
.to_string(),
645+
"trust store is empty"
646+
);
647+
// If the trust store otherwise does not contain the root we generated
648+
// the repo from, we should also fail.
649+
for trust_store in [
650+
vec![untrusted_root.buffer()],
651+
vec![untrusted_root.buffer(), untrusted_root.buffer()],
652+
] {
653+
assert_eq!(
654+
OmicronRepo::load(&logctx.log, &repo_dir, trust_store)
655+
.await
656+
.unwrap_err()
657+
.to_string(),
658+
"Failed to verify timestamp metadata: \
659+
Signature threshold of 1 not met for role timestamp \
660+
(0 valid signatures)"
661+
)
662+
}
663+
664+
logctx.cleanup_successful();
665+
}
666+
511667
#[tokio::test]
512668
async fn reject_artifacts_with_the_same_filename() {
513669
let log_config = ConfigLogging::File {
@@ -520,12 +676,16 @@ mod tests {
520676
&log_config,
521677
);
522678
let tempdir = Utf8TempDir::new().unwrap();
679+
let keys = vec![Key::generate_ed25519().unwrap()];
680+
let expiry = Utc::now() + Days::new(1);
681+
let root = crate::root::new_root(keys.clone(), expiry).await.unwrap();
523682
let mut repo = OmicronRepo::initialize(
524683
&logctx.log,
525684
tempdir.path(),
526685
"0.0.0".parse().unwrap(),
527-
vec![Key::generate_ed25519().unwrap()],
528-
Utc::now() + Days::new(1),
686+
keys,
687+
root,
688+
expiry,
529689
)
530690
.await
531691
.unwrap()

lib/src/root.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use tough::schema::{KeyHolder, RoleKeys, RoleType, Root};
1313

1414
use crate::key::Key;
1515

16-
pub(crate) async fn new_root(
16+
pub async fn new_root(
1717
keys: Vec<Key>,
1818
expires: DateTime<Utc>,
1919
) -> Result<SignedRole<Root>> {

0 commit comments

Comments
 (0)