From 016c59e031513ce6ac512b9221f2f40bb7f0d195 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Thu, 19 Oct 2023 09:26:20 -0500 Subject: [PATCH 01/13] sign: init Signed-off-by: Jack Leightcap Signed-off-by: Andrew Pan Co-authored-by: Jack Leightcap --- Cargo.toml | 14 +- src/bundle/mod.rs | 54 ++++++ src/errors.rs | 31 ++++ src/fulcio/mod.rs | 91 +++++++++- src/fulcio/models.rs | 116 ++++++++++++ src/lib.rs | 7 + src/oauth/mod.rs | 3 + src/oauth/token.rs | 101 +++++++++++ src/rekor/models/log_entry.rs | 23 ++- src/sign.rs | 324 ++++++++++++++++++++++++++++++++++ src/tuf/repository_helper.rs | 105 +++++++++++ 11 files changed, 862 insertions(+), 7 deletions(-) create mode 100644 src/bundle/mod.rs create mode 100644 src/fulcio/models.rs create mode 100644 src/oauth/token.rs create mode 100644 src/sign.rs diff --git a/Cargo.toml b/Cargo.toml index 3f72662e11..a945a9a6d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" repository = "https://github.com/sigstore/sigstore-rs" [features] -default = ["full-native-tls", "cached-client", "tuf"] +default = ["full-native-tls", "cached-client", "tuf", "sign"] wasm = ["getrandom/js"] full-native-tls = [ @@ -42,6 +42,8 @@ rekor = ["reqwest"] tuf = ["tough", "regex"] +sign = [] + cosign-native-tls = [ "oci-distribution/native-tls", "cert", @@ -72,7 +74,7 @@ async-trait = "0.1.52" base64 = "0.21.0" cached = { version = "0.46.0", optional = true, features = ["async"] } cfg-if = "1.0.0" -chrono = { version = "0.4.27", default-features = false } +chrono = { version = "0.4.27", default-features = false, features = ["serde"] } const-oid = "0.9.1" digest = { version = "0.10.3", default-features = false } ecdsa = { version = "0.16.7", features = ["pkcs8", "digest", "der", "signing"] } @@ -88,7 +90,7 @@ openidconnect = { version = "3.0", default-features = false, features = [ p256 = "0.13.2" p384 = "0.13" webbrowser = "0.8.4" -pem = "3.0" +pem = { version = "3.0", features = ["serde"] } pkcs1 = { version = "0.7.5", features = ["std"] } pkcs8 = { version = "0.10.2", features = [ "pem", @@ -110,17 +112,20 @@ serde_json = "1.0.79" serde_with = { version = "3.4.0", features = ["base64"] } sha2 = { version = "0.10.6", features = ["oid"] } signature = { version = "2.0" } +sigstore_protobuf_specs = "0.1.0-rc.2" thiserror = "1.0.30" tokio = { version = "1.17.0", features = ["rt"] } tough = { version = "0.14", features = ["http"], optional = true } tracing = "0.1.31" url = "2.2.2" -x509-cert = { version = "0.2.2", features = ["pem", "std"] } +x509-cert = { version = "0.2.2", features = ["builder", "pem", "std"] } crypto_secretbox = "0.1.1" zeroize = "1.5.7" rustls-webpki = { version = "0.102.0", features = ["alloc"] } rustls-pki-types = { version = "1.0.0", features = ["std"] } serde_repr = "0.1.16" +hex = "0.4.3" +json-syntax = { version = "0.9.6", features = ["canonicalize", "serde"] } [dev-dependencies] anyhow = { version = "1.0", features = ["backtrace"] } @@ -134,7 +139,6 @@ serial_test = "2.0.0" tempfile = "3.3.0" testcontainers = "0.15" tracing-subscriber = { version = "0.3.9", features = ["env-filter"] } -hex = "0.4.3" # cosign example mappings diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs new file mode 100644 index 0000000000..dabef9770f --- /dev/null +++ b/src/bundle/mod.rs @@ -0,0 +1,54 @@ +// Copyright 2023 The Sigstore Authors. +// +// 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. + +//! Useful types for Sigstore bundles. + +use std::fmt::Display; + +pub use sigstore_protobuf_specs::Bundle; + +// Known Sigstore bundle media types. +#[derive(Clone, Copy, Debug)] +pub enum Version { + Bundle0_1, + Bundle0_2, +} + +impl TryFrom<&str> for Version { + type Error = (); + + fn try_from(value: &str) -> Result { + match value { + "application/vnd.dev.sigstore.bundle+json;version=0.1" => Ok(Version::Bundle0_1), + "application/vnd.dev.sigstore.bundle+json;version=0.2" => Ok(Version::Bundle0_2), + _ => Err(()), + } + } +} + +impl From for &str { + fn from(value: Version) -> Self { + match value { + Version::Bundle0_1 => "application/vnd.dev.sigstore.bundle+json;version=0.1", + Version::Bundle0_2 => "application/vnd.dev.sigstore.bundle+json;version=0.2", + } + } +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str((*self).into())?; + Ok(()) + } +} diff --git a/src/errors.rs b/src/errors.rs index 430066cc65..8a53997e6b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -52,6 +52,9 @@ pub enum SigstoreError { #[error("invalid key format: {error}")] InvalidKeyFormat { error: String }, + #[error("Unable to parse identity token: {0}")] + IdentityTokenError(&'static str), + #[error("unmatched key type {key_typ} and signing scheme {scheme}")] UnmatchedKeyAndSigningScheme { key_typ: String, scheme: String }, @@ -70,6 +73,9 @@ pub enum SigstoreError { #[error("Public key verification error")] PublicKeyVerificationError, + #[error("X.509 certificate version is not V3")] + CertificateUnsupportedVersionError, + #[error("Certificate validity check failed: cannot be used before {0}")] CertificateValidityError(String), @@ -103,6 +109,12 @@ pub enum SigstoreError { #[error("Certificate pool error: {0}")] CertificatePoolError(&'static str), + #[error("Signing session expired")] + ExpiredSigningSession(), + + #[error("Fulcio request unsuccessful: {0}")] + FulcioClientError(&'static str), + #[error("Cannot fetch manifest of {image}: {error}")] RegistryFetchManifestError { image: String, error: String }, @@ -115,9 +127,19 @@ pub enum SigstoreError { #[error("Cannot push {image}: {error}")] RegistryPushError { image: String, error: String }, + #[error("Rekor request unsuccessful: {0}")] + RekorClientError(String), + + #[cfg(feature = "sign")] + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + #[error("OCI reference not valid: {reference}")] OciReferenceNotValidError { reference: String }, + #[error("Sigstore bundle malformed: {0}")] + SigstoreBundleMalformedError(String), + #[error("Layer doesn't have Sigstore media type")] SigstoreMediaTypeNotFoundError, @@ -155,6 +177,9 @@ pub enum SigstoreError { #[error("{0}")] VerificationConstraintError(String), + #[error("{0}")] + VerificationMaterialError(String), + #[error("{0}")] ApplyConstraintError(String), @@ -214,4 +239,10 @@ pub enum SigstoreError { #[error(transparent)] Ed25519PKCS8Error(#[from] ed25519_dalek::pkcs8::spki::Error), + + #[error(transparent)] + X509ParseError(#[from] x509_cert::der::Error), + + #[error(transparent)] + X509BuilderError(#[from] x509_cert::builder::Error), } diff --git a/src/fulcio/mod.rs b/src/fulcio/mod.rs index 2570a15f15..5734f5ae86 100644 --- a/src/fulcio/mod.rs +++ b/src/fulcio/mod.rs @@ -1,23 +1,33 @@ +mod models; + pub mod oauth; use crate::crypto::signing_key::SigStoreSigner; use crate::crypto::SigningScheme; use crate::errors::{Result, SigstoreError}; +use crate::fulcio::models::{CreateSigningCertificateRequest, SigningCertificate}; use crate::fulcio::oauth::OauthTokenProvider; +use crate::oauth::IdentityToken; use base64::{engine::general_purpose::STANDARD as BASE64_STD_ENGINE, Engine as _}; use openidconnect::core::CoreIdToken; -use reqwest::Body; +use pkcs8::der::Decode; +use reqwest::{header, Body}; use serde::ser::SerializeStruct; use serde::{Serialize, Serializer}; use std::convert::{TryFrom, TryInto}; use std::fmt::{Debug, Display, Formatter}; +use tracing::debug; use url::Url; +use x509_cert::Certificate; + +pub use models::CertificateResponse; /// Default public Fulcio server root. pub const FULCIO_ROOT: &str = "https://fulcio.sigstore.dev/"; /// Path within Fulcio to obtain a signing certificate. pub const SIGNING_CERT_PATH: &str = "api/v1/signingCert"; +pub const SIGNING_CERT_V2_PATH: &str = "api/v2/signingCert"; const CONTENT_TYPE_HEADER_NAME: &str = "content-type"; @@ -191,4 +201,83 @@ impl FulcioClient { Ok((signer, FulcioCert(cert))) } + + /// Request a certificate from Fulcio with the V2 endpoint. + /// + /// TODO(tnytown): This (and other API clients) probably be autogenerated. See sigstore-rs#209. + /// + /// https://github.com/sigstore/fulcio/blob/main/fulcio.proto + /// + /// Additionally, it might not be reasonable to expect callers to correctly construct and pass + /// in an X509 CSR. + pub fn request_cert_v2( + &self, + request: x509_cert::request::CertReq, + identity: &IdentityToken, + ) -> Result { + let client = reqwest::blocking::Client::new(); + + macro_rules! headers { + ($($key:expr => $val:expr),+) => { + { + let mut map = reqwest::header::HeaderMap::new(); + $( map.insert($key, $val.parse().unwrap()); )+ + map + } + } + } + let headers = headers!( + header::AUTHORIZATION => format!("Bearer {}", identity.to_string()), + header::CONTENT_TYPE => "application/json", + header::ACCEPT => "application/pem-certificate-chain" + ); + + let response: SigningCertificate = client + .post(self.root_url.join(SIGNING_CERT_V2_PATH)?) + .headers(headers) + .json(&CreateSigningCertificateRequest { + certificate_signing_request: request, + }) + .send()? + .json()?; + + let sct_embedded = matches!( + response, + SigningCertificate::SignedCertificateEmbeddedSct(_) + ); + let certs = match response { + SigningCertificate::SignedCertificateDetachedSct(ref sc) => &sc.chain.certificates, + SigningCertificate::SignedCertificateEmbeddedSct(ref sc) => &sc.chain.certificates, + }; + + if certs.len() < 2 { + return Err(SigstoreError::FulcioClientError( + "Certificate chain too short: certs.len() < 2", + )); + } + + let mut chain = certs + .iter() + .map(|pem| Certificate::from_der(pem.contents())) + .collect::, _>>()?; + let cert = chain + .drain(..1) + .next() + .expect("failed to drain certificates of checked length!"); + + // TODO(tnytown): Implement SCT extraction. + // see: https://github.com/RustCrypto/formats/pull/1134 + if sct_embedded { + debug!("PrecertificateSignedCertificateTimestamps isn't implemented yet in x509_cert."); + } else { + // No embedded SCT, Fulcio instance that provides detached SCT: + if let SigningCertificate::SignedCertificateDetachedSct(_sct) = response {} + }; + + Ok(CertificateResponse { + cert, + chain, + // sct, + }) + } } diff --git a/src/fulcio/models.rs b/src/fulcio/models.rs new file mode 100644 index 0000000000..b4e7dc367d --- /dev/null +++ b/src/fulcio/models.rs @@ -0,0 +1,116 @@ +// Copyright 2023 The Sigstore Authors. +// +// 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. + +//! Models for interfacing with Fulcio. +//! +//! https://github.com/sigstore/fulcio/blob/9da27be4fb64b85c907ab9ddd8a5d3cbd38041d4/fulcio.proto + +use base64::{engine::general_purpose::STANDARD as BASE64_STD_ENGINE, Engine as _}; +use pem::Pem; +use pkcs8::der::EncodePem; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_repr::Deserialize_repr; +use x509_cert::Certificate; + +fn serialize_x509_csr( + input: &x509_cert::request::CertReq, + ser: S, +) -> std::result::Result +where + S: Serializer, +{ + let encoded = input + .to_pem(pkcs8::LineEnding::CRLF) + .map_err(serde::ser::Error::custom)?; + let encoded = BASE64_STD_ENGINE.encode(encoded); + + ser.serialize_str(&encoded) +} + +fn deserialize_base64<'de, D>(de: D) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let buf: &str = Deserialize::deserialize(de)?; + + BASE64_STD_ENGINE + .decode(buf) + .map_err(serde::de::Error::custom) +} + +fn deserialize_inner_detached_sct<'de, D>(de: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + let buf = deserialize_base64(de)?; + + serde_json::from_slice(&buf).map_err(serde::de::Error::custom) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSigningCertificateRequest { + #[serde(serialize_with = "serialize_x509_csr")] + pub certificate_signing_request: x509_cert::request::CertReq, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SigningCertificate { + SignedCertificateDetachedSct(SigningCertificateDetachedSCT), + SignedCertificateEmbeddedSct(SigningCertificateEmbeddedSCT), +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SigningCertificateDetachedSCT { + pub chain: CertificateChain, + #[serde(deserialize_with = "deserialize_inner_detached_sct")] + pub signed_certificate_timestamp: InnerDetachedSCT, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SigningCertificateEmbeddedSCT { + pub chain: CertificateChain, +} + +#[derive(Deserialize)] +pub struct CertificateChain { + pub certificates: Vec, +} + +#[derive(Deserialize)] +pub struct InnerDetachedSCT { + pub sct_version: SCTVersion, + #[serde(deserialize_with = "deserialize_base64")] + pub id: Vec, + pub timestamp: u64, + #[serde(deserialize_with = "deserialize_base64")] + pub signature: Vec, + #[serde(deserialize_with = "deserialize_base64")] + pub extensions: Vec, +} + +#[derive(Deserialize_repr, PartialEq, Debug)] +#[repr(u8)] +pub enum SCTVersion { + V1 = 0, +} + +pub struct CertificateResponse { + pub cert: Certificate, + pub chain: Vec, + // pub sct: InnerDetachedSCT, +} diff --git a/src/lib.rs b/src/lib.rs index afea5441a0..0c648b5f62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -283,3 +283,10 @@ pub mod rekor; #[cfg(feature = "tuf")] pub mod tuf; + +// Don't export yet -- these types should only be useful internally. +mod bundle; +pub use bundle::Bundle; + +#[cfg(feature = "sign")] +pub mod sign; diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 24d240b35f..a5419e7fce 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -14,3 +14,6 @@ // limitations under the License. pub mod openidflow; + +mod token; +pub use token::IdentityToken; diff --git a/src/oauth/token.rs b/src/oauth/token.rs new file mode 100644 index 0000000000..3c61731d87 --- /dev/null +++ b/src/oauth/token.rs @@ -0,0 +1,101 @@ +// Copyright 2023 The Sigstore Authors. +// +// 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 chrono::{DateTime, Utc}; +use openidconnect::core::CoreIdToken; +use serde::Deserialize; + +use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine as _}; + +use crate::errors::SigstoreError; + +#[derive(Deserialize)] +pub struct Claims { + pub aud: String, + #[serde(with = "chrono::serde::ts_seconds")] + pub exp: DateTime, + pub email: String, +} + +pub type UnverifiedClaims = Claims; + +/// A Sigstore token. +pub struct IdentityToken { + original_token: String, + // header + claims: UnverifiedClaims, + // signature +} + +impl IdentityToken { + /// Returns the **unverified** claim set for the token. + /// + /// The [UnverifiedClaims] returned from this method should not be used to enforce security + /// invariants. + pub fn unverified_claims(&self) -> &UnverifiedClaims { + &self.claims + } + + pub fn appears_to_be_expired(&self) -> bool { + Utc::now() > self.claims.exp + } +} + +impl TryFrom<&str> for IdentityToken { + type Error = SigstoreError; + + fn try_from(value: &str) -> Result { + let parts: Vec<_> = value.split('.').take(3).collect(); + + if parts.len() != 3 { + return Err(SigstoreError::IdentityTokenError("Malformed JWT")); + } + let &[_, claims, _] = &parts[..] else { + unreachable!() + }; + + let claims = base64 + .decode(claims) + .or(Err(SigstoreError::IdentityTokenError( + "Malformed JWT: Unable to decode claims", + )))?; + let claims: Claims = serde_json::from_slice(&claims).or(Err( + SigstoreError::IdentityTokenError("Malformed JWT: claims JSON malformed"), + ))?; + if claims.aud != "sigstore" { + return Err(SigstoreError::IdentityTokenError("Not a Sigstore JWT")); + } + + Ok(IdentityToken { + original_token: value.to_owned(), + claims, + }) + } +} + +impl From for IdentityToken { + fn from(value: CoreIdToken) -> Self { + value + .to_string() + .as_str() + .try_into() + .expect("Token conversion failed") + } +} + +impl ToString for IdentityToken { + fn to_string(&self) -> String { + self.original_token.clone() + } +} diff --git a/src/rekor/models/log_entry.rs b/src/rekor/models/log_entry.rs index b3b86f2fef..5caadd3db2 100644 --- a/src/rekor/models/log_entry.rs +++ b/src/rekor/models/log_entry.rs @@ -1,3 +1,18 @@ +// +// Copyright 2023 The Sigstore Authors. +// +// 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 crate::errors::SigstoreError; use crate::rekor::TreeSize; use base64::{engine::general_purpose::STANDARD as BASE64_STD_ENGINE, Engine as _}; @@ -14,7 +29,7 @@ use super::{ /// Stores the response returned by Rekor after making a new entry #[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct LogEntry { pub uuid: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -95,4 +110,10 @@ pub struct InclusionProof { pub log_index: i64, pub root_hash: String, pub tree_size: TreeSize, + + /// A snapshot of the transparency log's state at a specific point in time, + /// in [Signed Note format]. + /// + /// [Signed Note format]: https://github.com/transparency-dev/formats/blob/main/log/README.md + pub checkpoint: String, } diff --git a/src/sign.rs b/src/sign.rs new file mode 100644 index 0000000000..80f39919a4 --- /dev/null +++ b/src/sign.rs @@ -0,0 +1,324 @@ +// Copyright 2023 The Sigstore Authors. +// +// 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::cell::OnceCell; +use std::io::{self, Read}; +use std::str::FromStr; +use std::time::SystemTime; + +use base64::{engine::general_purpose::STANDARD as base64, Engine as _}; +use hex; +use json_syntax::Print; +use p256::NistP256; +use pkcs8::der::{Encode, EncodePem}; +use sha2::{Digest, Sha256}; +use signature::DigestSigner; +use sigstore_protobuf_specs::{ + Bundle, DevSigstoreBundleV1VerificationMaterial, DevSigstoreCommonV1HashOutput, + DevSigstoreCommonV1LogId, DevSigstoreCommonV1MessageSignature, + DevSigstoreCommonV1X509Certificate, DevSigstoreCommonV1X509CertificateChain, + DevSigstoreRekorV1Checkpoint, DevSigstoreRekorV1InclusionPromise, + DevSigstoreRekorV1InclusionProof, DevSigstoreRekorV1KindVersion, + DevSigstoreRekorV1TransparencyLogEntry, +}; +use url::Url; +use x509_cert::builder::{Builder, RequestBuilder as CertRequestBuilder}; +use x509_cert::{ext::pkix as x509_ext, name::Name as X509Name}; + +use crate::bundle::Version; +use crate::errors::{Result as SigstoreResult, SigstoreError}; +use crate::fulcio::oauth::OauthTokenProvider; +use crate::fulcio::{self, FulcioClient, FULCIO_ROOT}; +use crate::oauth::IdentityToken; +use crate::rekor::apis::configuration::Configuration as RekorConfiguration; +use crate::rekor::apis::entries_api::create_log_entry; +use crate::rekor::models::LogEntry; +use crate::rekor::models::{hashedrekord, proposed_entry::ProposedEntry as ProposedLogEntry}; + +/// A Sigstore signing session. +/// +/// Sessions hold a provided user identity and key materials tied to that identity. A single +/// session may be used to sign multiple items. For more information, see [`Self::sign()`]. +pub struct SigningSession<'ctx> { + context: &'ctx SigningContext, + identity_token: IdentityToken, + private_key: ecdsa::SigningKey, + certs: OnceCell, +} + +impl<'ctx> SigningSession<'ctx> { + fn new(context: &'ctx SigningContext, identity_token: IdentityToken) -> Self { + Self { + context, + identity_token, + private_key: Self::private_key(), + certs: Default::default(), + } + } + + fn private_key() -> ecdsa::SigningKey { + let mut rng = rand::thread_rng(); + let secret_key = p256::SecretKey::random(&mut rng); + ecdsa::SigningKey::from(secret_key) + } + + fn certs(&self) -> SigstoreResult<&fulcio::CertificateResponse> { + fn init_certs( + fulcio: &FulcioClient, + identity: &IdentityToken, + private_key: &ecdsa::SigningKey, + ) -> SigstoreResult { + let subject = X509Name::from_str(&format!( + "emailAddress={}", + identity.unverified_claims().email + )) + .expect("failed to initialize constant X509Name!"); + + let mut builder = CertRequestBuilder::new(subject, private_key)?; + builder + .add_extension(&x509_ext::BasicConstraints { + ca: false, + path_len_constraint: None, + }) + .expect("failed to initialize constant BasicConstaints!"); + + let cert_req = builder + .build::() + .expect("CSR signing failed"); + fulcio.request_cert_v2(cert_req, identity) + } + + let resp = init_certs( + &self.context.fulcio, + &self.identity_token, + &self.private_key, + )?; + Ok(self.certs.get_or_init(|| resp)) + } + + /// Check if the session's identity token or key material is expired. + /// + /// If the session is expired, it cannot be used for signing operations, and a new session + /// must be created with a fresh identity token. + pub fn is_expired(&self) -> bool { + self.identity_token.appears_to_be_expired() + || self.certs().is_ok_and(|certs| { + let not_after = certs + .cert + .tbs_certificate + .validity + .not_after + .to_system_time(); + + SystemTime::now() > not_after + }) + } + + /// Signs for the input with the session's identity. If the identity is expired, + /// [SigstoreError::ExpiredSigningSession] is returned. + /// + /// TODO(tnytown): Make this async safe. We may need to make the underlying trait functions + /// implementations async and wrap them with executors for the sync variants. Our async + /// variants would also need to use async variants of common traits (AsyncRead? AsyncHasher?) + pub fn sign(&self, input: &mut R) -> SigstoreResult { + if self.is_expired() { + return Err(SigstoreError::ExpiredSigningSession()); + } + + let mut hasher = Sha256::new(); + io::copy(input, &mut hasher)?; + + let certs = self.certs()?; + // TODO(tnytown): Verify SCT here. + + // Sign artifact. + let input_hash: &[u8] = &hasher.clone().finalize(); + let artifact_signature: p256::ecdsa::Signature = self.private_key.sign_digest(hasher); + + // Prepare inputs. + let b64_artifact_signature = base64.encode(artifact_signature.to_der()); + let cert = &certs.cert; + + // Create the transparency log entry. + let proposed_entry = ProposedLogEntry::Hashedrekord { + api_version: "0.0.1".to_owned(), + spec: hashedrekord::Spec { + signature: hashedrekord::Signature { + content: b64_artifact_signature.clone(), + public_key: hashedrekord::PublicKey::new( + base64.encode(cert.to_pem(pkcs8::LineEnding::CRLF)?), + ), + }, + data: hashedrekord::Data { + hash: hashedrekord::Hash { + algorithm: hashedrekord::AlgorithmKind::sha256, + value: hex::encode(input_hash), + }, + }, + }, + }; + + // HACK(tnytown): We aren't async yet. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let entry = rt + .block_on(create_log_entry(&self.context.rekor_config, proposed_entry)) + .map_err(|err| { + eprintln!("original: {err:?}"); + SigstoreError::RekorClientError(err.to_string()) + })?; + + // TODO(tnytown): Maybe run through the verification flow here? See sigstore-rs#296. + + Ok(SigningArtifact { + input_digest: base64.encode(input_hash), + cert: cert.to_der()?, + b64_signature: b64_artifact_signature, + log_entry: entry, + }) + } +} + +/// A Sigstore signing context. +/// +/// Contexts hold Fulcio (CA) and Rekor (CT) configurations which signing sessions can be +/// constructed against. Use [`Self::production()`] to create a context against the public-good +/// Sigstore infrastructure. +pub struct SigningContext { + fulcio: FulcioClient, + rekor_config: RekorConfiguration, +} + +impl SigningContext { + /// Manually constructs a [SigningContext] from its constituent data. + pub fn new(fulcio: FulcioClient, rekor_config: RekorConfiguration) -> Self { + Self { + fulcio, + rekor_config, + } + } + + /// Returns a [SigningContext] configured against the public-good production Sigstore + /// infrastructure. + pub fn production() -> Self { + Self::new( + FulcioClient::new( + Url::parse(FULCIO_ROOT).expect("constant FULCIO root fails to parse!"), + crate::fulcio::TokenProvider::Oauth(OauthTokenProvider::default()), + ), + Default::default(), + ) + } + + /// Configures and returns a [SigningSession] with the held context. + pub fn signer(&self, identity_token: IdentityToken) -> SigningSession { + SigningSession::new(self, identity_token) + } +} + +/// A signature and its associated metadata. +pub struct SigningArtifact { + input_digest: String, + cert: Vec, + b64_signature: String, + log_entry: LogEntry, +} + +impl SigningArtifact { + /// Consumes the signing artifact and produces a Sigstore [Bundle]. + /// + /// The resulting bundle can be serialized with with [serde_json]. + pub fn to_bundle(self) -> Bundle { + #[inline] + fn hex_to_base64>(hex: S) -> String { + let decoded = hex::decode(hex.as_ref()).expect("Malformed data in Rekor response"); + base64.encode(decoded) + } + + // NOTE: We explicitly only include the leaf certificate in the bundle's "chain" + // here: the specs explicitly forbid the inclusion of the root certificate, + // and discourage inclusion of any intermediates (since they're in the root of + // trust already). + let x_509_certificate_chain = Some(DevSigstoreCommonV1X509CertificateChain { + certificates: Some(vec![DevSigstoreCommonV1X509Certificate { + raw_bytes: Some(base64.encode(&self.cert)), + }]), + }); + + let inclusion_proof = if let Some(proof) = self.log_entry.verification.inclusion_proof { + let hashes = proof.hashes.iter().map(hex_to_base64).collect(); + Some(DevSigstoreRekorV1InclusionProof { + checkpoint: Some(DevSigstoreRekorV1Checkpoint { + envelope: Some(proof.checkpoint), + }), + hashes: Some(hashes), + log_index: Some(proof.log_index.to_string()), + root_hash: Some(hex_to_base64(proof.root_hash)), + tree_size: Some(proof.tree_size.to_string()), + }) + } else { + None + }; + + let canonicalized_body = { + let mut body = json_syntax::to_value(self.log_entry.body) + .expect("failed to parse constructed Body!"); + body.canonicalize(); + Some(base64.encode(body.compact_print().to_string())) + }; + + // TODO(tnytown): When we fix `sigstore_protobuf_specs`, have the Rekor client APIs convert + // responses into types from the specs as opposed to returning the raw `LogEntry` model type. + let tlog_entry = DevSigstoreRekorV1TransparencyLogEntry { + canonicalized_body, + inclusion_promise: Some(DevSigstoreRekorV1InclusionPromise { + // XX: sigstore-python deserializes the SET from base64 here because their protobuf + // library transparently serializes `bytes` fields as base64. + signed_entry_timestamp: Some(self.log_entry.verification.signed_entry_timestamp), + }), + inclusion_proof, + integrated_time: Some(self.log_entry.integrated_time.to_string()), + kind_version: Some(DevSigstoreRekorV1KindVersion { + kind: Some("hashedrekord".to_owned()), + version: Some("0.0.1".to_owned()), + }), + log_id: Some(DevSigstoreCommonV1LogId { + key_id: Some(hex_to_base64(self.log_entry.log_i_d)), + }), + log_index: Some(self.log_entry.log_index.to_string()), + }; + + let verification_material = Some(DevSigstoreBundleV1VerificationMaterial { + public_key: None, + timestamp_verification_data: None, + tlog_entries: Some(vec![tlog_entry]), + x_509_certificate_chain, + }); + + let message_signature = Some(DevSigstoreCommonV1MessageSignature { + message_digest: Some(DevSigstoreCommonV1HashOutput { + algorithm: Some("SHA2_256".to_owned()), + digest: Some(self.input_digest), + }), + signature: Some(self.b64_signature), + }); + Bundle { + dsse_envelope: None, + media_type: Some(Version::Bundle0_2.to_string()), + message_signature, + verification_material, + } + } +} diff --git a/src/tuf/repository_helper.rs b/src/tuf/repository_helper.rs index 74747d363e..e821c230e4 100644 --- a/src/tuf/repository_helper.rs +++ b/src/tuf/repository_helper.rs @@ -154,6 +154,111 @@ impl RepositoryHelper { } } +/// Given a `range`, checks that the the current time is not before `start`. If +/// `allow_expired` is `false`, also checks that the current time is not after +/// `end`. +fn is_timerange_valid(range: Option, allow_expired: bool) -> bool { + let time = chrono::Utc::now(); + + match range { + // If there was no validity period specified, the key is always valid. + None => true, + // Active: if the current time is before the starting period, we are not yet valid. + Some(range) if time < range.start => false, + // If we want Expired keys, then the key is valid at this point. + _ if allow_expired => true, + // Otherwise, check that we are in range if the range has an end. + Some(range) => match range.end { + None => true, + Some(end) => time <= end, + }, + } +} + +/// Download a file stored inside of a TUF repository, try to reuse a local +/// cache when possible. +/// +/// * `repository`: TUF repository holding the file +/// * `target_name`: TUF representation of the file to be downloaded +/// * `local_file`: location where the file should be downloaded +/// +/// This function will reuse the local copy of the file if contents +/// didn't change. +/// This check is done by comparing the digest of the local file, if found, +/// with the digest reported inside of the TUF repository metadata. +/// +/// **Note well:** the `local_file` is updated whenever its contents are +/// outdated. +fn fetch_target_or_reuse_local_cache( + repository: &tough::Repository, + target_name: &TargetName, + local_file: Option<&PathBuf>, +) -> Result> { + let (local_file_outdated, local_file_contents) = if let Some(path) = local_file { + is_local_file_outdated(repository, target_name, path) + } else { + Ok((true, None)) + }?; + + let data = if local_file_outdated { + let data = fetch_target(repository, target_name)?; + if let Some(path) = local_file { + // update the local file to have latest data from the TUF repo + fs::write(path, data.clone())?; + } + data + } else { + local_file_contents + .expect("local file contents to not be 'None'") + .as_bytes() + .to_owned() + }; + + Ok(data) +} + +/// Download a file from a TUF repository +fn fetch_target(repository: &tough::Repository, target_name: &TargetName) -> Result> { + let data: Vec; + match repository.read_target(target_name).map_err(Box::new)? { + None => Err(SigstoreError::TufTargetNotFoundError( + target_name.raw().to_string(), + )), + Some(reader) => { + data = read_to_end(reader)?; + Ok(data) + } + } +} + +/// Compares the checksum of a local file, with the digest reported inside of +/// TUF repository metadata +fn is_local_file_outdated( + repository: &tough::Repository, + target_name: &TargetName, + local_file: &Path, +) -> Result<(bool, Option)> { + let target = repository + .targets() + .signed + .targets + .get(target_name) + .ok_or_else(|| SigstoreError::TufTargetNotFoundError(target_name.raw().to_string()))?; + + if local_file.exists() { + let data = fs::read_to_string(local_file)?; + let local_checksum = Sha256::digest(data.clone()); + let expected_digest: Vec = target.hashes.sha256.to_vec(); + + if local_checksum.as_slice() == expected_digest.as_slice() { + // local data is not outdated + Ok((false, Some(data))) + } else { + Ok(keys) + } + } +} + #[cfg(test)] mod tests { use super::super::constants::*; From 2f02745c2b05b8906fa9b3b0bd895cccf69d41de Mon Sep 17 00:00:00 2001 From: Jack Leightcap Date: Thu, 16 Nov 2023 14:10:37 -0500 Subject: [PATCH 02/13] sign: review: string impls Signed-off-by: Jack Leightcap --- src/bundle/mod.rs | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index dabef9770f..2b9b9cb73c 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -21,34 +21,15 @@ pub use sigstore_protobuf_specs::Bundle; // Known Sigstore bundle media types. #[derive(Clone, Copy, Debug)] pub enum Version { - Bundle0_1, + _Bundle0_1, Bundle0_2, } -impl TryFrom<&str> for Version { - type Error = (); - - fn try_from(value: &str) -> Result { - match value { - "application/vnd.dev.sigstore.bundle+json;version=0.1" => Ok(Version::Bundle0_1), - "application/vnd.dev.sigstore.bundle+json;version=0.2" => Ok(Version::Bundle0_2), - _ => Err(()), - } - } -} - -impl From for &str { - fn from(value: Version) -> Self { - match value { - Version::Bundle0_1 => "application/vnd.dev.sigstore.bundle+json;version=0.1", - Version::Bundle0_2 => "application/vnd.dev.sigstore.bundle+json;version=0.2", - } - } -} - impl Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str((*self).into())?; - Ok(()) + f.write_str(match &self { + Version::_Bundle0_1 => "application/vnd.dev.sigstore.bundle+json;version=0.1", + Version::Bundle0_2 => "application/vnd.dev.sigstore.bundle+json;version=0.2", + }) } } From ec15caca2dc404e5886830bf87ca887cba10f09e Mon Sep 17 00:00:00 2001 From: Andrew Pan <3821575+tnytown@users.noreply.github.com> Date: Thu, 16 Nov 2023 17:12:27 -0600 Subject: [PATCH 03/13] Apply suggestions from code review Signed-off-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> --- src/fulcio/mod.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/fulcio/mod.rs b/src/fulcio/mod.rs index 5734f5ae86..512488b737 100644 --- a/src/fulcio/mod.rs +++ b/src/fulcio/mod.rs @@ -256,14 +256,11 @@ impl FulcioClient { )); } - let mut chain = certs + let cert = Certificate::from_der(certs[0].contents())?; + let chain = certs[1..] .iter() .map(|pem| Certificate::from_der(pem.contents())) .collect::, _>>()?; - let cert = chain - .drain(..1) - .next() - .expect("failed to drain certificates of checked length!"); // TODO(tnytown): Implement SCT extraction. // see: https://github.com/RustCrypto/formats/pull/1134 From 6b2273717fb545db07b1440c2d2e560518a38326 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Fri, 17 Nov 2023 14:31:42 -0600 Subject: [PATCH 04/13] oauth/token: collect JWT token parts into array Signed-off-by: Andrew Pan --- src/oauth/token.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/oauth/token.rs b/src/oauth/token.rs index 3c61731d87..2df2d6c664 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -56,17 +56,14 @@ impl TryFrom<&str> for IdentityToken { type Error = SigstoreError; fn try_from(value: &str) -> Result { - let parts: Vec<_> = value.split('.').take(3).collect(); - - if parts.len() != 3 { - return Err(SigstoreError::IdentityTokenError("Malformed JWT")); - } - let &[_, claims, _] = &parts[..] else { - unreachable!() - }; + let parts: [&str; 3] = value + .split('.') + .collect::>() + .try_into() + .or(Err(SigstoreError::IdentityTokenError("Malformed JWT")))?; let claims = base64 - .decode(claims) + .decode(parts[1]) .or(Err(SigstoreError::IdentityTokenError( "Malformed JWT: Unable to decode claims", )))?; From 724d88940c56b1961d31843bb884c9cc65fa7145 Mon Sep 17 00:00:00 2001 From: Andrew Pan <3821575+tnytown@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:05:21 -0600 Subject: [PATCH 05/13] Apply suggestions from code review Co-authored-by: Flavio Castelli Signed-off-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> --- src/sign.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sign.rs b/src/sign.rs index 80f39919a4..b61053f06b 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -239,7 +239,7 @@ pub struct SigningArtifact { impl SigningArtifact { /// Consumes the signing artifact and produces a Sigstore [Bundle]. /// - /// The resulting bundle can be serialized with with [serde_json]. + /// The resulting bundle can be serialized with [serde_json]. pub fn to_bundle(self) -> Bundle { #[inline] fn hex_to_base64>(hex: S) -> String { From 1808cabb48605365672a84b2c73a20d050210bb3 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Tue, 28 Nov 2023 13:19:34 -0600 Subject: [PATCH 06/13] sign: more changes from code review Signed-off-by: Andrew Pan --- src/oauth/token.rs | 13 +++++++++++-- src/sign.rs | 10 +++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/oauth/token.rs b/src/oauth/token.rs index 2df2d6c664..d2b82ac2ec 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -25,6 +25,8 @@ pub struct Claims { pub aud: String, #[serde(with = "chrono::serde::ts_seconds")] pub exp: DateTime, + #[serde(with = "chrono::serde::ts_seconds")] + pub nbf: Option>, pub email: String, } @@ -47,8 +49,15 @@ impl IdentityToken { &self.claims } - pub fn appears_to_be_expired(&self) -> bool { - Utc::now() > self.claims.exp + /// Returns whether or not this token is within its self-stated validity period. + pub fn in_validity_period(&self) -> bool { + let now = Utc::now(); + + if let Some(nbf) = self.claims.nbf { + nbf <= now && now < self.claims.exp + } else { + now < self.claims.exp + } } } diff --git a/src/sign.rs b/src/sign.rs index b61053f06b..31f805133a 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -82,8 +82,7 @@ impl<'ctx> SigningSession<'ctx> { let subject = X509Name::from_str(&format!( "emailAddress={}", identity.unverified_claims().email - )) - .expect("failed to initialize constant X509Name!"); + ))?; let mut builder = CertRequestBuilder::new(subject, private_key)?; builder @@ -112,7 +111,7 @@ impl<'ctx> SigningSession<'ctx> { /// If the session is expired, it cannot be used for signing operations, and a new session /// must be created with a fresh identity token. pub fn is_expired(&self) -> bool { - self.identity_token.appears_to_be_expired() + !self.identity_token.in_validity_period() || self.certs().is_ok_and(|certs| { let not_after = certs .cert @@ -175,10 +174,7 @@ impl<'ctx> SigningSession<'ctx> { .build()?; let entry = rt .block_on(create_log_entry(&self.context.rekor_config, proposed_entry)) - .map_err(|err| { - eprintln!("original: {err:?}"); - SigstoreError::RekorClientError(err.to_string()) - })?; + .map_err(|err| SigstoreError::RekorClientError(err.to_string()))?; // TODO(tnytown): Maybe run through the verification flow here? See sigstore-rs#296. From 91e6775eac3a88c91eec7d83a2ca72ef91962d06 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Tue, 28 Nov 2023 15:08:28 -0600 Subject: [PATCH 07/13] sign: discard OnceCell pattern on SigningSession Signed-off-by: Andrew Pan --- src/oauth/token.rs | 2 +- src/sign.rs | 108 ++++++++++++++++++++------------------------- 2 files changed, 50 insertions(+), 60 deletions(-) diff --git a/src/oauth/token.rs b/src/oauth/token.rs index d2b82ac2ec..c1824a17ce 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -25,7 +25,7 @@ pub struct Claims { pub aud: String, #[serde(with = "chrono::serde::ts_seconds")] pub exp: DateTime, - #[serde(with = "chrono::serde::ts_seconds")] + #[serde(with = "chrono::serde::ts_seconds_option")] pub nbf: Option>, pub email: String, } diff --git a/src/sign.rs b/src/sign.rs index 31f805133a..58f413e9a8 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::cell::OnceCell; use std::io::{self, Read}; -use std::str::FromStr; use std::time::SystemTime; use base64::{engine::general_purpose::STANDARD as base64, Engine as _}; @@ -33,8 +31,9 @@ use sigstore_protobuf_specs::{ DevSigstoreRekorV1TransparencyLogEntry, }; use url::Url; +use x509_cert::attr::{AttributeTypeAndValue, AttributeValue}; use x509_cert::builder::{Builder, RequestBuilder as CertRequestBuilder}; -use x509_cert::{ext::pkix as x509_ext, name::Name as X509Name}; +use x509_cert::ext::pkix as x509_ext; use crate::bundle::Version; use crate::errors::{Result as SigstoreResult, SigstoreError}; @@ -54,56 +53,50 @@ pub struct SigningSession<'ctx> { context: &'ctx SigningContext, identity_token: IdentityToken, private_key: ecdsa::SigningKey, - certs: OnceCell, + certs: fulcio::CertificateResponse, } impl<'ctx> SigningSession<'ctx> { - fn new(context: &'ctx SigningContext, identity_token: IdentityToken) -> Self { - Self { + fn new(context: &'ctx SigningContext, identity_token: IdentityToken) -> SigstoreResult { + let (private_key, certs) = Self::materials(&context.fulcio, &identity_token)?; + Ok(Self { context, identity_token, - private_key: Self::private_key(), - certs: Default::default(), - } - } - - fn private_key() -> ecdsa::SigningKey { - let mut rng = rand::thread_rng(); - let secret_key = p256::SecretKey::random(&mut rng); - ecdsa::SigningKey::from(secret_key) + private_key, + certs, + }) } - fn certs(&self) -> SigstoreResult<&fulcio::CertificateResponse> { - fn init_certs( - fulcio: &FulcioClient, - identity: &IdentityToken, - private_key: &ecdsa::SigningKey, - ) -> SigstoreResult { - let subject = X509Name::from_str(&format!( - "emailAddress={}", - identity.unverified_claims().email - ))?; + fn materials( + fulcio: &FulcioClient, + token: &IdentityToken, + ) -> SigstoreResult<(ecdsa::SigningKey, fulcio::CertificateResponse)> { + let subject = + // SEQUENCE OF RelativeDistinguishedName + vec![ + // SET OF AttributeTypeAndValue + vec![ + // AttributeTypeAndValue, `emailAddress=...`` + AttributeTypeAndValue { + oid: const_oid::db::rfc3280::EMAIL_ADDRESS, + value: AttributeValue::new( + pkcs8::der::Tag::Utf8String, + token.unverified_claims().email.as_ref(), + )?, + } + ].try_into()? + ].into(); - let mut builder = CertRequestBuilder::new(subject, private_key)?; - builder - .add_extension(&x509_ext::BasicConstraints { - ca: false, - path_len_constraint: None, - }) - .expect("failed to initialize constant BasicConstaints!"); - - let cert_req = builder - .build::() - .expect("CSR signing failed"); - fulcio.request_cert_v2(cert_req, identity) - } - - let resp = init_certs( - &self.context.fulcio, - &self.identity_token, - &self.private_key, - )?; - Ok(self.certs.get_or_init(|| resp)) + let mut rng = rand::thread_rng(); + let private_key = ecdsa::SigningKey::from(p256::SecretKey::random(&mut rng)); + let mut builder = CertRequestBuilder::new(subject, &private_key)?; + builder.add_extension(&x509_ext::BasicConstraints { + ca: false, + path_len_constraint: None, + })?; + + let cert_req = builder.build::()?; + Ok((private_key, fulcio.request_cert_v2(cert_req, token)?)) } /// Check if the session's identity token or key material is expired. @@ -111,17 +104,15 @@ impl<'ctx> SigningSession<'ctx> { /// If the session is expired, it cannot be used for signing operations, and a new session /// must be created with a fresh identity token. pub fn is_expired(&self) -> bool { - !self.identity_token.in_validity_period() - || self.certs().is_ok_and(|certs| { - let not_after = certs - .cert - .tbs_certificate - .validity - .not_after - .to_system_time(); - - SystemTime::now() > not_after - }) + let not_after = self + .certs + .cert + .tbs_certificate + .validity + .not_after + .to_system_time(); + + !self.identity_token.in_validity_period() || SystemTime::now() > not_after } /// Signs for the input with the session's identity. If the identity is expired, @@ -138,7 +129,6 @@ impl<'ctx> SigningSession<'ctx> { let mut hasher = Sha256::new(); io::copy(input, &mut hasher)?; - let certs = self.certs()?; // TODO(tnytown): Verify SCT here. // Sign artifact. @@ -147,7 +137,7 @@ impl<'ctx> SigningSession<'ctx> { // Prepare inputs. let b64_artifact_signature = base64.encode(artifact_signature.to_der()); - let cert = &certs.cert; + let cert = &self.certs.cert; // Create the transparency log entry. let proposed_entry = ProposedLogEntry::Hashedrekord { @@ -219,7 +209,7 @@ impl SigningContext { } /// Configures and returns a [SigningSession] with the held context. - pub fn signer(&self, identity_token: IdentityToken) -> SigningSession { + pub fn signer(&self, identity_token: IdentityToken) -> SigstoreResult { SigningSession::new(self, identity_token) } } From e56a6f8707ce6c13e3f9a260088353e9747bad1d Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Tue, 28 Nov 2023 15:16:35 -0600 Subject: [PATCH 08/13] treewide: bump webpki to `0.102.0-alpha.7` Signed-off-by: Andrew Pan --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a945a9a6d1..9c1104a4d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,7 @@ url = "2.2.2" x509-cert = { version = "0.2.2", features = ["builder", "pem", "std"] } crypto_secretbox = "0.1.1" zeroize = "1.5.7" -rustls-webpki = { version = "0.102.0", features = ["alloc"] } +rustls-webpki = { version = "0.102.0-alpha.7", features = ["alloc"] } rustls-pki-types = { version = "1.0.0", features = ["std"] } serde_repr = "0.1.16" hex = "0.4.3" From 123cee157b58c36d963c563509196136df0b4ca3 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Tue, 28 Nov 2023 18:52:57 -0600 Subject: [PATCH 09/13] errors: `&'static str` -> `String` Signed-off-by: Andrew Pan --- src/crypto/certificate_pool.rs | 2 +- src/errors.rs | 11 +++++++---- src/oauth/token.rs | 16 ++++++++-------- src/tuf/mod.rs | 4 ++-- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/crypto/certificate_pool.rs b/src/crypto/certificate_pool.rs index 0f8b247cd7..11ee756133 100644 --- a/src/crypto/certificate_pool.rs +++ b/src/crypto/certificate_pool.rs @@ -61,7 +61,7 @@ impl<'a> CertificatePool<'a> { let cert_pem = pem::parse(cert_pem)?; if cert_pem.tag() != "CERTIFICATE" { return Err(SigstoreError::CertificatePoolError( - "PEM file is not a certificate", + "PEM file is not a certificate".into(), )); } diff --git a/src/errors.rs b/src/errors.rs index 8a53997e6b..22b87e95cb 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -53,7 +53,7 @@ pub enum SigstoreError { InvalidKeyFormat { error: String }, #[error("Unable to parse identity token: {0}")] - IdentityTokenError(&'static str), + IdentityTokenError(String), #[error("unmatched key type {key_typ} and signing scheme {scheme}")] UnmatchedKeyAndSigningScheme { key_typ: String, scheme: String }, @@ -107,13 +107,13 @@ pub enum SigstoreError { CertificateWithIncompleteSubjectAlternativeName, #[error("Certificate pool error: {0}")] - CertificatePoolError(&'static str), + CertificatePoolError(String), #[error("Signing session expired")] ExpiredSigningSession(), #[error("Fulcio request unsuccessful: {0}")] - FulcioClientError(&'static str), + FulcioClientError(String), #[error("Cannot fetch manifest of {image}: {error}")] RegistryFetchManifestError { image: String, error: String }, @@ -130,6 +130,9 @@ pub enum SigstoreError { #[error("Rekor request unsuccessful: {0}")] RekorClientError(String), + #[error(transparent)] + JoinError(#[from] tokio::task::JoinError), + #[cfg(feature = "sign")] #[error(transparent)] ReqwestError(#[from] reqwest::Error), @@ -166,7 +169,7 @@ pub enum SigstoreError { TufTargetNotFoundError(String), #[error("{0}")] - TufMetadataError(&'static str), + TufMetadataError(String), #[error(transparent)] IOError(#[from] std::io::Error), diff --git a/src/oauth/token.rs b/src/oauth/token.rs index c1824a17ce..d06348ffbb 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -65,22 +65,22 @@ impl TryFrom<&str> for IdentityToken { type Error = SigstoreError; fn try_from(value: &str) -> Result { - let parts: [&str; 3] = value - .split('.') - .collect::>() - .try_into() - .or(Err(SigstoreError::IdentityTokenError("Malformed JWT")))?; + let parts: [&str; 3] = value.split('.').collect::>().try_into().or(Err( + SigstoreError::IdentityTokenError("Malformed JWT".into()), + ))?; let claims = base64 .decode(parts[1]) .or(Err(SigstoreError::IdentityTokenError( - "Malformed JWT: Unable to decode claims", + "Malformed JWT: Unable to decode claims".into(), )))?; let claims: Claims = serde_json::from_slice(&claims).or(Err( - SigstoreError::IdentityTokenError("Malformed JWT: claims JSON malformed"), + SigstoreError::IdentityTokenError("Malformed JWT: claims JSON malformed".into()), ))?; if claims.aud != "sigstore" { - return Err(SigstoreError::IdentityTokenError("Not a Sigstore JWT")); + return Err(SigstoreError::IdentityTokenError( + "Not a Sigstore JWT".into(), + )); } Ok(IdentityToken { diff --git a/src/tuf/mod.rs b/src/tuf/mod.rs index 4df3c757c2..201eae8968 100644 --- a/src/tuf/mod.rs +++ b/src/tuf/mod.rs @@ -195,7 +195,7 @@ impl Repository for SigstoreRepository { if certs.is_empty() { Err(SigstoreError::TufMetadataError( - "Fulcio certificates not found", + "Fulcio certificates not found".into(), )) } else { Ok(certs) @@ -215,7 +215,7 @@ impl Repository for SigstoreRepository { if keys.len() != 1 { Err(SigstoreError::TufMetadataError( - "Did not find exactly 1 active Rekor key", + "Did not find exactly 1 active Rekor key".into(), )) } else { Ok(keys) From 029248897ed8d51012e845dda877232ee6d91937 Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Tue, 28 Nov 2023 18:54:12 -0600 Subject: [PATCH 10/13] sign: construct `AsyncSigningSession` Signed-off-by: Andrew Pan --- Cargo.toml | 1 + src/fulcio/mod.rs | 14 +++--- src/sign.rs | 122 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 103 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c1104a4d1..aa6f9226ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,7 @@ signature = { version = "2.0" } sigstore_protobuf_specs = "0.1.0-rc.2" thiserror = "1.0.30" tokio = { version = "1.17.0", features = ["rt"] } +tokio-util = { version = "0.7.10", features = ["io-util"] } tough = { version = "0.14", features = ["http"], optional = true } tracing = "0.1.31" url = "2.2.2" diff --git a/src/fulcio/mod.rs b/src/fulcio/mod.rs index 512488b737..81477cab6e 100644 --- a/src/fulcio/mod.rs +++ b/src/fulcio/mod.rs @@ -204,18 +204,18 @@ impl FulcioClient { /// Request a certificate from Fulcio with the V2 endpoint. /// - /// TODO(tnytown): This (and other API clients) probably be autogenerated. See sigstore-rs#209. + /// TODO(tnytown): This (and other API clients) should be autogenerated. See sigstore-rs#209. /// /// https://github.com/sigstore/fulcio/blob/main/fulcio.proto /// /// Additionally, it might not be reasonable to expect callers to correctly construct and pass /// in an X509 CSR. - pub fn request_cert_v2( + pub async fn request_cert_v2( &self, request: x509_cert::request::CertReq, identity: &IdentityToken, ) -> Result { - let client = reqwest::blocking::Client::new(); + let client = reqwest::Client::new(); macro_rules! headers { ($($key:expr => $val:expr),+) => { @@ -238,8 +238,10 @@ impl FulcioClient { .json(&CreateSigningCertificateRequest { certificate_signing_request: request, }) - .send()? - .json()?; + .send() + .await? + .json() + .await?; let sct_embedded = matches!( response, @@ -252,7 +254,7 @@ impl FulcioClient { if certs.len() < 2 { return Err(SigstoreError::FulcioClientError( - "Certificate chain too short: certs.len() < 2", + "Certificate chain too short: certs.len() < 2".into(), )); } diff --git a/src/sign.rs b/src/sign.rs index 58f413e9a8..1d4b36838e 100644 --- a/src/sign.rs +++ b/src/sign.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Types for signing artifacts and producing Sigstore Bundles. + use std::io::{self, Read}; use std::time::SystemTime; @@ -30,6 +32,8 @@ use sigstore_protobuf_specs::{ DevSigstoreRekorV1InclusionProof, DevSigstoreRekorV1KindVersion, DevSigstoreRekorV1TransparencyLogEntry, }; +use tokio::io::AsyncRead; +use tokio_util::io::SyncIoBridge; use url::Url; use x509_cert::attr::{AttributeTypeAndValue, AttributeValue}; use x509_cert::builder::{Builder, RequestBuilder as CertRequestBuilder}; @@ -45,20 +49,26 @@ use crate::rekor::apis::entries_api::create_log_entry; use crate::rekor::models::LogEntry; use crate::rekor::models::{hashedrekord, proposed_entry::ProposedEntry as ProposedLogEntry}; -/// A Sigstore signing session. +/// An asynchronous Sigstore signing session. /// /// Sessions hold a provided user identity and key materials tied to that identity. A single -/// session may be used to sign multiple items. For more information, see [`Self::sign()`]. -pub struct SigningSession<'ctx> { +/// session may be used to sign multiple items. For more information, see [`AsyncSigningSession::sign`](Self::sign). +/// +/// This signing session operates asynchronously. To construct a synchronous [SigningSession], +/// use [`SigningContext::signer()`]. +pub struct AsyncSigningSession<'ctx> { context: &'ctx SigningContext, identity_token: IdentityToken, private_key: ecdsa::SigningKey, certs: fulcio::CertificateResponse, } -impl<'ctx> SigningSession<'ctx> { - fn new(context: &'ctx SigningContext, identity_token: IdentityToken) -> SigstoreResult { - let (private_key, certs) = Self::materials(&context.fulcio, &identity_token)?; +impl<'ctx> AsyncSigningSession<'ctx> { + async fn new( + context: &'ctx SigningContext, + identity_token: IdentityToken, + ) -> SigstoreResult> { + let (private_key, certs) = Self::materials(&context.fulcio, &identity_token).await?; Ok(Self { context, identity_token, @@ -67,7 +77,7 @@ impl<'ctx> SigningSession<'ctx> { }) } - fn materials( + async fn materials( fulcio: &FulcioClient, token: &IdentityToken, ) -> SigstoreResult<(ecdsa::SigningKey, fulcio::CertificateResponse)> { @@ -76,7 +86,7 @@ impl<'ctx> SigningSession<'ctx> { vec![ // SET OF AttributeTypeAndValue vec![ - // AttributeTypeAndValue, `emailAddress=...`` + // AttributeTypeAndValue, `emailAddress=...` AttributeTypeAndValue { oid: const_oid::db::rfc3280::EMAIL_ADDRESS, value: AttributeValue::new( @@ -96,7 +106,7 @@ impl<'ctx> SigningSession<'ctx> { })?; let cert_req = builder.build::()?; - Ok((private_key, fulcio.request_cert_v2(cert_req, token)?)) + Ok((private_key, fulcio.request_cert_v2(cert_req, token).await?)) } /// Check if the session's identity token or key material is expired. @@ -115,20 +125,11 @@ impl<'ctx> SigningSession<'ctx> { !self.identity_token.in_validity_period() || SystemTime::now() > not_after } - /// Signs for the input with the session's identity. If the identity is expired, - /// [SigstoreError::ExpiredSigningSession] is returned. - /// - /// TODO(tnytown): Make this async safe. We may need to make the underlying trait functions - /// implementations async and wrap them with executors for the sync variants. Our async - /// variants would also need to use async variants of common traits (AsyncRead? AsyncHasher?) - pub fn sign(&self, input: &mut R) -> SigstoreResult { + async fn sign_digest(&self, hasher: Sha256) -> SigstoreResult { if self.is_expired() { return Err(SigstoreError::ExpiredSigningSession()); } - let mut hasher = Sha256::new(); - io::copy(input, &mut hasher)?; - // TODO(tnytown): Verify SCT here. // Sign artifact. @@ -158,12 +159,8 @@ impl<'ctx> SigningSession<'ctx> { }, }; - // HACK(tnytown): We aren't async yet. - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - let entry = rt - .block_on(create_log_entry(&self.context.rekor_config, proposed_entry)) + let entry = create_log_entry(&self.context.rekor_config, proposed_entry) + .await .map_err(|err| SigstoreError::RekorClientError(err.to_string()))?; // TODO(tnytown): Maybe run through the verification flow here? See sigstore-rs#296. @@ -175,13 +172,72 @@ impl<'ctx> SigningSession<'ctx> { log_entry: entry, }) } + + /// Signs for the input with the session's identity. If the identity is expired, + /// [SigstoreError::ExpiredSigningSession] is returned. + pub async fn sign( + &self, + input: R, + ) -> SigstoreResult { + if self.is_expired() { + return Err(SigstoreError::ExpiredSigningSession()); + } + + let mut sync_input = SyncIoBridge::new(input); + let hasher = tokio::task::spawn_blocking(move || -> SigstoreResult<_> { + let mut hasher = Sha256::new(); + io::copy(&mut sync_input, &mut hasher)?; + Ok(hasher) + }) + .await??; + + self.sign_digest(hasher).await + } +} + +/// A synchronous Sigstore signing session. +/// +/// Sessions hold a provided user identity and key materials tied to that identity. A single +/// session may be used to sign multiple items. For more information, see [`SigningSession::sign`](Self::sign). +/// +/// This signing session operates synchronously, thus it cannot be used in an asynchronous context. +/// To construct an asynchronous [SigningSession], use [`SigningContext::async_signer()`]. +pub struct SigningSession<'ctx> { + inner: AsyncSigningSession<'ctx>, + rt: tokio::runtime::Runtime, +} + +impl<'ctx> SigningSession<'ctx> { + fn new(ctx: &'ctx SigningContext, token: IdentityToken) -> SigstoreResult { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let inner = rt.block_on(AsyncSigningSession::new(ctx, token))?; + Ok(Self { inner, rt }) + } + + /// Check if the session's identity token or key material is expired. + /// + /// If the session is expired, it cannot be used for signing operations, and a new session + /// must be created with a fresh identity token. + pub fn is_expired(&self) -> bool { + self.inner.is_expired() + } + + /// Signs for the input with the session's identity. If the identity is expired, + /// [SigstoreError::ExpiredSigningSession] is returned. + pub fn sign(&self, mut input: R) -> SigstoreResult { + let mut hasher = Sha256::new(); + io::copy(&mut input, &mut hasher)?; + self.rt.block_on(self.inner.sign_digest(hasher)) + } } /// A Sigstore signing context. /// /// Contexts hold Fulcio (CA) and Rekor (CT) configurations which signing sessions can be -/// constructed against. Use [`Self::production()`] to create a context against the public-good -/// Sigstore infrastructure. +/// constructed against. Use [`SigningContext::production`](Self::production) to create a context against +/// the public-good Sigstore infrastructure. pub struct SigningContext { fulcio: FulcioClient, rekor_config: RekorConfiguration, @@ -208,7 +264,17 @@ impl SigningContext { ) } - /// Configures and returns a [SigningSession] with the held context. + /// Configures and returns an [AsyncSigningSession] with the held context. + pub async fn async_signer( + &self, + identity_token: IdentityToken, + ) -> SigstoreResult { + AsyncSigningSession::new(self, identity_token).await + } + + /// Configures and returns a [SigningContext] with the held context. + /// + /// Async contexts must use [`SigningContext::async_signer`](Self::async_signer). pub fn signer(&self, identity_token: IdentityToken) -> SigstoreResult { SigningSession::new(self, identity_token) } From 04dece2f09feb48d45636d563fea2d78aa613fdd Mon Sep 17 00:00:00 2001 From: Andrew Pan Date: Wed, 29 Nov 2023 10:22:54 -0600 Subject: [PATCH 11/13] tuf: remove chaff in RepositoryHelper Signed-off-by: Andrew Pan --- src/tuf/repository_helper.rs | 379 ++++++++++++++++------------------- 1 file changed, 173 insertions(+), 206 deletions(-) diff --git a/src/tuf/repository_helper.rs b/src/tuf/repository_helper.rs index e821c230e4..a581619638 100644 --- a/src/tuf/repository_helper.rs +++ b/src/tuf/repository_helper.rs @@ -13,7 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use rustls_pki_types::CertificateDer; use sha2::{Digest, Sha256}; use std::fs; use std::io::Read; @@ -21,13 +20,14 @@ use std::path::{Path, PathBuf}; use tough::{RepositoryLoader, TargetName}; use url::Url; -use super::super::errors::{Result, SigstoreError}; -use super::trustroot::{CertificateAuthority, TimeRange, TransparencyLogInstance, TrustedRoot}; +use super::{ + super::errors::{Result, SigstoreError}, + constants::{SIGSTORE_FULCIO_CERT_TARGET_REGEX, SIGSTORE_REKOR_PUB_KEY_TARGET}, +}; pub(crate) struct RepositoryHelper { repository: tough::Repository, checkout_dir: Option, - trusted_root: Option, } impl RepositoryHelper { @@ -40,7 +40,7 @@ impl RepositoryHelper { where R: Read, { - let repository = RepositoryLoader::new(SIGSTORE_ROOT, metadata_base, target_base) + let repository = RepositoryLoader::new(root, metadata_base, target_base) .expiration_enforcement(tough::ExpirationEnforcement::Safe) .load() .map_err(Box::new)?; @@ -48,130 +48,68 @@ impl RepositoryHelper { Ok(Self { repository, checkout_dir: checkout_dir.map(|s| s.to_owned()), - trusted_root: None, }) } - pub(crate) fn from_repo(repo: tough::Repository, checkout_dir: Option<&Path>) -> Self { - Self { - repository: repo, - checkout_dir: checkout_dir.map(|s| s.to_owned()), - trusted_root: None, - } - } - - fn trusted_root(&self) -> Result<&TrustedRoot> { - if let Some(result) = self.trusted_root { - return Ok(&result); - } - - let trusted_root_target = TargetName::new("trusted_root.json").map_err(Box::new)?; - let local_path = self - .checkout_dir - .as_ref() - .map(|d| d.join(trusted_root_target.raw())); - - let data = fetch_target_or_reuse_local_cache( - &self.repository, - &trusted_root_target, - local_path.as_ref(), - )?; - - let result = serde_json::from_slice(&data[..])?; - Ok(self.trusted_root.insert(result)) - } - - #[inline] - fn tlog_keys(&self, tlogs: &Vec) -> Vec<&[u8]> { - let mut result = Vec::new(); - - for key in tlogs { - // We won't accept expired keys for transparency logs. - if !is_timerange_valid(key.public_key.valid_for, false) { - continue; - } - - if let Some(raw) = key.public_key.raw_bytes { - result.push(&raw[..]); - } - } - - result - } - - #[inline] - fn ca_keys(&self, cas: &Vec, allow_expired: bool) -> Vec<&[u8]> { - let mut certs = Vec::new(); - - for ca in cas { - if !is_timerange_valid(Some(ca.valid_for), allow_expired) { - continue; - } - - let certs_in_ca = ca.cert_chain.certificates; - certs.extend(certs_in_ca.iter().map(|cert| &cert.raw_bytes[..])); - } - - return certs; - } - /// Fetch Fulcio certificates from the given TUF repository or reuse /// the local cache if its contents are not outdated. /// /// The contents of the local cache are updated when they are outdated. - pub(crate) fn fulcio_certs(&self) -> Result> { - let root = self.trusted_root()?; - - // Allow expired certificates: they may have been active when the - // certificate was used to sign. - let certs = self.ca_keys(&root.certificate_authorities, true); - let certs: Vec<_> = certs.iter().map(|v| CertificateDer::from(*v)).collect(); - - if certs.is_empty() { - Err(SigstoreError::TufMetadataError( - "Fulcio certificates not found", - )) - } else { - Ok(certs) + pub(crate) fn fulcio_certs(&self) -> Result> { + let fulcio_target_names = self.fulcio_cert_target_names(); + let mut certs = vec![]; + + for fulcio_target_name in &fulcio_target_names { + let local_fulcio_path = self + .checkout_dir + .as_ref() + .map(|d| Path::new(d).join(fulcio_target_name.raw())); + + let cert_data = fetch_target_or_reuse_local_cache( + &self.repository, + fulcio_target_name, + local_fulcio_path.as_ref(), + )?; + certs.push(crate::registry::Certificate { + data: cert_data, + encoding: crate::registry::CertificateEncoding::Pem, + }); } + Ok(certs) } - /// Fetch Rekor public keys from the given TUF repository or reuse + fn fulcio_cert_target_names(&self) -> Vec { + self.repository + .targets() + .signed + .targets_iter() + .filter_map(|(target_name, _target)| { + if SIGSTORE_FULCIO_CERT_TARGET_REGEX.is_match(target_name.raw()) { + Some(target_name.clone()) + } else { + None + } + }) + .collect() + } + + /// Fetch Rekor public key from the given TUF repository or reuse /// the local cache if it's not outdated. /// /// The contents of the local cache are updated when they are outdated. - pub(crate) fn rekor_keys(&self) -> Result> { - let root = self.trusted_root()?; - let keys = self.tlog_keys(&root.tlogs); + pub(crate) fn rekor_pub_key(&self) -> Result> { + let rekor_target_name = TargetName::new(SIGSTORE_REKOR_PUB_KEY_TARGET).map_err(Box::new)?; - if keys.len() != 1 { - Err(SigstoreError::TufMetadataError( - "Did not find exactly 1 active Rekor key", - )) - } else { - Ok(keys) - } - } -} + let local_rekor_path = self + .checkout_dir + .as_ref() + .map(|d| Path::new(d).join(SIGSTORE_REKOR_PUB_KEY_TARGET)); -/// Given a `range`, checks that the the current time is not before `start`. If -/// `allow_expired` is `false`, also checks that the current time is not after -/// `end`. -fn is_timerange_valid(range: Option, allow_expired: bool) -> bool { - let time = chrono::Utc::now(); - - match range { - // If there was no validity period specified, the key is always valid. - None => true, - // Active: if the current time is before the starting period, we are not yet valid. - Some(range) if time < range.start => false, - // If we want Expired keys, then the key is valid at this point. - _ if allow_expired => true, - // Otherwise, check that we are in range if the range has an end. - Some(range) => match range.end { - None => true, - Some(end) => time <= end, - }, + fetch_target_or_reuse_local_cache( + &self.repository, + &rekor_target_name, + local_rekor_path.as_ref(), + ) } } @@ -254,11 +192,20 @@ fn is_local_file_outdated( // local data is not outdated Ok((false, Some(data))) } else { - Ok(keys) + Ok((true, None)) } + } else { + Ok((true, None)) } } +/// Gets the goods from a read and makes a Vec +fn read_to_end(mut reader: R) -> Result> { + let mut v = Vec::new(); + reader.read_to_end(&mut v)?; + Ok(v) +} + #[cfg(test)] mod tests { use super::super::constants::*; @@ -305,92 +252,61 @@ mod tests { )) })?; // It's fine to ignore timestamp.json expiration inside of test env - let repo = RepositoryLoader::new(SIGSTORE_ROOT, metadata_base_url, target_base_url) - .expiration_enforcement(tough::ExpirationEnforcement::Unsafe) - .load() - .map_err(Box::new)?; + let repo = + RepositoryLoader::new(SIGSTORE_ROOT.as_bytes(), metadata_base_url, target_base_url) + .expiration_enforcement(tough::ExpirationEnforcement::Unsafe) + .load() + .map_err(Box::new)?; Ok(repo) } - fn find_target(name: &str) -> Result { - let path = test_data().join("repository").join("targets"); - - for entry in fs::read_dir(path)? { - let path = entry?.path(); - if path.is_dir() { - continue; - } - - // Heuristic: Filter for consistent snapshot targets. SHA256 hashes in hexadecimal - // comprise of 64 characters, so our filename must be at least that long. The TUF repo - // shouldn't ever contain paths with invalid Unicode (knock on wood), so we're doing - // the lossy OsStr conversion here. - let filename = path.file_name().unwrap().to_str().unwrap(); - if filename.len() < 64 { - continue; - } - - // Heuristic: see if the filename is in consistent snapshot format (.). - // NB: The consistent snapshot prefix should be ASCII, so indexing the string as - // bytes is safe enough. - if filename.as_bytes()[64] != b'.' { - continue; - } - - // At this point, we're probably dealing with a consistent snapshot. - // Check if the name matches. - if filename.ends_with(name) { - return Ok(path); - } - } - - Err(SigstoreError::UnexpectedError( - "Couldn't find a matching target".to_string(), - )) - } - - fn check_against_disk(helper: &RepositoryHelper) { - let mut actual: Vec<&[u8]> = helper - .fulcio_certs() - .expect("fulcio certs could not be read") - .iter() - .map(|c| c.as_ref()) - .collect(); - let expected = ["fulcio.crt.pem", "fulcio_v1.crt.pem"].iter().map(|t| { - let path = find_target(t)?; - Ok(fs::read(path)?) - }); - let mut expected = expected - .collect::>>>() - .expect("could not find targets"); - actual.sort(); - expected.sort(); - - assert_eq!(actual, expected, "The fulcio cert is not what was expected"); - - let actual = helper.rekor_keys().expect("rekor key cannot be read"); - let expected = fs::read(find_target("rekor.pub").expect("could not find targets")) - .expect("cannot read rekor key from test data"); - let expected = pem::parse(expected).unwrap(); - assert_eq!(expected.tag(), "PUBLIC KEY"); - - assert_eq!( - actual, - &[expected.contents()], - "The rekor key is not what was expected" - ); - } - #[test] fn get_files_without_using_local_cache() { let repository = local_tuf_repo().expect("Local TUF repo should not fail"); let helper = RepositoryHelper { repository, checkout_dir: None, - trusted_root: None, }; - check_against_disk(&helper); + let mut actual = helper.fulcio_certs().expect("fulcio certs cannot be read"); + actual.sort(); + let mut expected: Vec = + ["fulcio.crt.pem", "fulcio_v1.crt.pem"] + .iter() + .map(|filename| { + let data = fs::read( + test_data() + .join("repository") + .join("targets") + .join(filename), + ) + .unwrap_or_else(|_| panic!("cannot read {} from test data", filename)); + crate::registry::Certificate { + data, + encoding: crate::registry::CertificateEncoding::Pem, + } + }) + .collect(); + expected.sort(); + + assert_eq!( + actual, expected, + "The fulcio cert read from the TUF repository is not what was expected" + ); + + let actual = helper.rekor_pub_key().expect("rekor key cannot be read"); + let expected = fs::read( + test_data() + .join("repository") + .join("targets") + .join("rekor.pub"), + ) + .expect("cannot read rekor key from test data"); + + assert_eq!( + actual, expected, + "The rekor key read from the TUF repository is not what was expected" + ); } #[test] @@ -401,10 +317,42 @@ mod tests { let helper = RepositoryHelper { repository, checkout_dir: Some(cache_dir.path().to_path_buf()), - trusted_root: None, }; - check_against_disk(&helper); + let mut actual = helper.fulcio_certs().expect("fulcio certs cannot be read"); + actual.sort(); + let mut expected: Vec = + ["fulcio.crt.pem", "fulcio_v1.crt.pem"] + .iter() + .map(|filename| { + let data = fs::read( + test_data() + .join("repository") + .join("targets") + .join(filename), + ) + .unwrap_or_else(|_| panic!("cannot read {} from test data", filename)); + crate::registry::Certificate { + data, + encoding: crate::registry::CertificateEncoding::Pem, + } + }) + .collect(); + expected.sort(); + + assert_eq!( + actual, expected, + "The fulcio cert read from the cache dir is not what was expected" + ); + + let expected = helper.rekor_pub_key().expect("rekor key cannot be read"); + let actual = fs::read(cache_dir.path().join("rekor.pub")) + .expect("cannot read rekor key from cache dir"); + + assert_eq!( + actual, expected, + "The rekor key read from the cache dir is not what was expected" + ); } #[test] @@ -417,8 +365,8 @@ mod tests { .expect("Cannot write file to cache dir"); } fs::write( - cache_dir.path().join("trusted_root.json"), - b"fake trusted root", + cache_dir.path().join(SIGSTORE_REKOR_PUB_KEY_TARGET), + b"fake rekor", ) .expect("Cannot write file to cache dir"); @@ -426,22 +374,41 @@ mod tests { let helper = RepositoryHelper { repository, checkout_dir: Some(cache_dir.path().to_path_buf()), - trusted_root: None, }; - check_against_disk(&helper); - } + let mut actual = helper.fulcio_certs().expect("fulcio certs cannot be read"); + actual.sort(); + let mut expected: Vec = + ["fulcio.crt.pem", "fulcio_v1.crt.pem"] + .iter() + .map(|filename| { + let data = fs::read( + test_data() + .join("repository") + .join("targets") + .join(filename), + ) + .unwrap_or_else(|_| panic!("cannot read {} from test data", filename)); + crate::registry::Certificate { + data, + encoding: crate::registry::CertificateEncoding::Pem, + } + }) + .collect(); + expected.sort(); - #[test] - fn deser_trusted_root() { - let metadata_base_path = test_data().join("repository"); - let targets_base_path = metadata_base_path.join("targets"); + assert_eq!( + actual, expected, + "The fulcio cert read from the TUF repository is not what was expected" + ); - let repository = local_tuf_repo().expect("Local TUF repo should not fail"); - let helper = RepositoryHelper::from_repo(repository, None); + let expected = helper.rekor_pub_key().expect("rekor key cannot be read"); + let actual = fs::read(cache_dir.path().join("rekor.pub")) + .expect("cannot read rekor key from cache dir"); - helper - .trusted_root() - .expect("Trusted Root should deserialize"); + assert_eq!( + actual, expected, + "The rekor key read from the cache dir is not what was expected" + ); } } From dc1a33405f46d50e00bffc785f0ac48509cbee44 Mon Sep 17 00:00:00 2001 From: Jack Leightcap Date: Wed, 13 Dec 2023 12:26:15 -0500 Subject: [PATCH 12/13] sign: cleanup docstring Signed-off-by: Jack Leightcap --- src/oauth/token.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/oauth/token.rs b/src/oauth/token.rs index d06348ffbb..5db3c311f9 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -35,9 +35,7 @@ pub type UnverifiedClaims = Claims; /// A Sigstore token. pub struct IdentityToken { original_token: String, - // header claims: UnverifiedClaims, - // signature } impl IdentityToken { From 275698d8422ea00f65c5f0ff7da1db180a7657cd Mon Sep 17 00:00:00 2001 From: Jack Leightcap <30168080+jleightcap@users.noreply.github.com> Date: Wed, 13 Dec 2023 12:44:09 -0500 Subject: [PATCH 13/13] Update src/oauth/token.rs Co-authored-by: Andrew Pan <3821575+tnytown@users.noreply.github.com> Signed-off-by: Jack Leightcap <30168080+jleightcap@users.noreply.github.com> --- src/oauth/token.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/oauth/token.rs b/src/oauth/token.rs index 5db3c311f9..b5b304f9ed 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -26,6 +26,7 @@ pub struct Claims { #[serde(with = "chrono::serde::ts_seconds")] pub exp: DateTime, #[serde(with = "chrono::serde::ts_seconds_option")] + #[serde(default)] pub nbf: Option>, pub email: String, }