sigstore 0.14.0

An experimental crate to interact with sigstore
Documentation
//
// 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 as _, engine::general_purpose::STANDARD as BASE64_STD_ENGINE};

use crate::crypto::CosignVerificationKey;
use crate::crypto::merkle::hex_to_hash_output;
use crate::errors::SigstoreError::UnexpectedError;
use crate::rekor::models::InclusionProof;
use crate::rekor::models::checkpoint::SignedCheckpoint;

use serde::{Deserialize, Serialize};
use serde_json::{Error, Value, json};
use std::collections::HashMap;
use std::str::FromStr;

use super::{
    AlpineAllOf, HashedrekordAllOf, HelmAllOf, IntotoAllOf, JarAllOf, RekordAllOf, Rfc3161AllOf,
    RpmAllOf, TufAllOf,
};

/// Stores the response returned by Rekor after making a new entry
#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct LogEntry {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uuid: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attestation: Option<Attestation>,
    pub body: Body,
    pub integrated_time: i64,
    pub log_i_d: String,
    pub log_index: i64,
    pub verification: Verification,
}

impl FromStr for LogEntry {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut log_entry_map = serde_json::from_str::<HashMap<&str, Value>>(s)?;
        log_entry_map.entry("body").and_modify(|body| {
            let decoded_body = serde_json::to_value(
                decode_body(body.as_str().expect("Failed to parse Body"))
                    .expect("Failed to decode Body"),
            )
            .expect("Serialization failed");
            *body = json!(decoded_body);
        });
        let log_entry_str = serde_json::to_string(&log_entry_map)?;
        Ok(serde_json::from_str::<LogEntry>(&log_entry_str).expect("Serialization failed"))
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
#[allow(non_camel_case_types)]
pub enum Body {
    alpine(AlpineAllOf),
    helm(HelmAllOf),
    jar(JarAllOf),
    rfc3161(Rfc3161AllOf),
    rpm(RpmAllOf),
    tuf(TufAllOf),
    intoto(IntotoAllOf),
    hashedrekord(HashedrekordAllOf),
    rekord(RekordAllOf),
}

impl Default for Body {
    fn default() -> Self {
        Self::hashedrekord(Default::default())
    }
}

fn decode_body(s: &str) -> Result<Body, SigstoreError> {
    let decoded = BASE64_STD_ENGINE.decode(s)?;
    serde_json::from_slice(&decoded).map_err(SigstoreError::from)
}

#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attestation {
    // This field is just a place holder
    // Not sure what is stored inside the Attestation struct, it is empty for now
    #[serde(skip_serializing_if = "Option::is_none")]
    dummy: Option<String>,
}

/// Stores the signature over the artifact's logID, logIndex, body and integratedTime.
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Verification {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub inclusion_proof: Option<RekorInclusionProof>,
    pub signed_entry_timestamp: String,
}

impl LogEntry {
    /// Verifies that the log entry was included by a log in possession of `rekor_key`.
    ///
    /// Example:
    /// ```rust,no_run
    /// use sigstore::rekor::apis::configuration::Configuration;
    /// use sigstore::rekor::apis::pubkey_api::get_public_key;
    /// use sigstore::rekor::apis::tlog_api::get_log_info;
    /// use sigstore::crypto::{CosignVerificationKey, SigningScheme};
    /// #[tokio::main]
    /// async fn main() {
    ///     use sigstore::rekor::apis::entries_api::get_log_entry_by_index;
    ///     let rekor_config = Configuration::default();
    ///     // Important: in practice obtain the rekor key via TUF repo or another secure channel!
    ///     let rekor_key = get_public_key(&rekor_config, None)
    ///         .await
    ///         .expect("failed to fetch pubkey from remote log");
    ///     let rekor_key =  CosignVerificationKey::from_pem(
    ///         rekor_key.as_bytes(),
    ///         &SigningScheme::ECDSA_P256_SHA256_ASN1,
    ///     ).expect("failed to parse rekor key");
    ///
    ///     // fetch log info and then the most recent entry
    ///     let log_info = get_log_info(&rekor_config)
    ///         .await
    ///         .expect("failed to fetch log info");
    ///     let entry = get_log_entry_by_index(&rekor_config, (log_info.tree_size - 1) as i32)
    ///         .await.expect("failed to fetch log entry");
    ///     entry.verify_inclusion(&rekor_key)
    ///         .expect("failed to verify inclusion");
    /// }
    /// ```
    pub fn verify_inclusion(&self, rekor_key: &CosignVerificationKey) -> Result<(), SigstoreError> {
        let api_proof = self
            .verification
            .inclusion_proof
            .as_ref()
            .ok_or_else(|| UnexpectedError("missing inclusion proof".to_string()))?;
        let proof = InclusionProof::try_from(api_proof)
            .map_err(|e| UnexpectedError(format!("Failed to convert inclusion proof: {e}")))?;
        let buf = serde_json_canonicalizer::to_vec(&self.body).map_err(|e| {
            SigstoreError::UnexpectedError(format!(
                "Cannot create canonical JSON representation of body: {e:?}"
            ))
        })?;

        proof.verify(&buf, rekor_key)
    }
}

/// Stores the signature over the artifact's logID, logIndex, body and integratedTime.
///
/// This struct is used for API (de)serialization in queries to Rekor
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RekorInclusionProof {
    pub hashes: Vec<String>,
    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,
}

impl TryFrom<&RekorInclusionProof> for InclusionProof {
    type Error = crate::errors::SigstoreError;

    fn try_from(api: &RekorInclusionProof) -> Result<Self, Self::Error> {
        let hashes = api
            .hashes
            .iter()
            .map(hex_to_hash_output)
            .map(|r| r.map(Into::into))
            .collect::<Result<_, _>>()?;

        let root_hash = hex_to_hash_output(&api.root_hash)?;

        let checkpoint = if api.checkpoint.is_empty() {
            None
        } else {
            Some(
                SignedCheckpoint::decode(&api.checkpoint)
                    .map_err(|e| SigstoreError::ParseCheckpointError(format!("{:?}", e)))?,
            )
        };

        Ok(InclusionProof {
            hashes,
            log_index: api.log_index,
            root_hash: root_hash.into(),
            tree_size: api.tree_size,
            checkpoint,
        })
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use crate::crypto::{CosignVerificationKey, SigningScheme};

    use super::LogEntry;

    const LOG_ENTRY: &str = r#"
    {
        "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI0N2MxZGI5ZmI1ZmU3ZmY2NmUzZDdjMTViMmNhNWQzYTA0NmVlOGY0YWEwNDNkZWRkMzE3ZTQ2YjMyMWM0MzkwIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURVell6d3o4SEdhVXRXNUwvb0VNNGc1MFVvSUtzNXhuV1B0amFyeHRKckxBSWhBTzkwRTl2NGd5MmZUcytJbHM4OFczOXhldEUzS3NqRHN0cXF6NXNQMGVITSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTkRWRU5EUVdFclowRjNTVUpCWjBsSFFWbEhjMEZMUVhkTlFXOUhRME54UjFOTk5EbENRVTFEVFVOdmVFUlVRVXhDWjA1V1FrRk5UVUpJVW13S1l6TlJlRWRVUVZoQ1owNVdRa0Z2VFVWSVVteGpNMUZuV1RKV2VXUkhiRzFoVjA1b1pFZFZkMGhvWTA1TmFrbDNUbXBKTkUxcVFYbFBSRlY0VjJoalRncE5ha2wzVG1wSk5FMXFRVEJQUkZWNFYycEJjVTFSTUhkRGQxbEVWbEZSUkVSQlVqQmFXRTR3VFZKcmQwWjNXVVJXVVZGTFJFSkNNRnBZVGpCSlIwNXNDbU51VW5CYWJXeHFXVmhTYkUxR2EzZEZkMWxJUzI5YVNYcHFNRU5CVVZsSlMyOWFTWHBxTUVSQlVXTkVVV2RCUlVSQ1VISnBNMEp3VlhZNVRYRndVMlFLWlVoWlJXVjRZM3BqV0RKWmRHRkJXRGxDVjB4VVkyVm9Za2MxUnpkUFVGcHNVekZ2Y0hWRldXMVViVEJhY2pKTmNXcHBiV05xTHpjNFpFSTJNbUpFWWdwSlMwcDZTbUZQUW5kRVEwSjJWRUZrUW1kT1ZraFJORVZHWjFGVlFXcHBSMUJFUWsxSFNXSTFZVEp3YUhkeU1VVTJURXBtVTJGdmQwaDNXVVJXVWpCcUNrSkNaM2RHYjBGVldWTldPV1V5TjFKVmN6TTViRTg1VWsxTVlXaGtZVzV0V1VaM2QwUm5XVVJXVWpCUVFWRklMMEpCVVVSQloyVkJUVUpOUjBFeFZXUUtTbEZSVFUxQmIwZERRM05IUVZGVlJrSjNUVVJOUVhkSFFURlZaRVYzUlVJdmQxRkRUVUZCZDBkM1dVUldVakJTUVZGSUwwSkNSWGRFTkVWT1pFZFdlZ3BrUlVJd1dsaE9NRXh0VG5aaVZFRnlRbWR2Y2tKblJVVkJXVTh2VFVGRlFrSkNNVzlrU0ZKM1kzcHZka3d5V21oaE1sWm9XVEpPZG1SWE5UQmplVFV3Q2xwWVRqQk1iVTUyWWxSQlMwSm5aM0ZvYTJwUFVGRlJSRUZuVGtsQlJFSkdRV2xCVXpWTVZHeHlXak54Vm5aUGIyVjBibGh4V21JdmEzcEVURWRhYXpNS1MySkJTMGhMYmpkemFqQkZabEZKYUVGT05uTldVRTlyWlU1SlVYYzJlVEJNUVhNMVlrbGFXVkExUVVoTWFFUm9SRTlhZG1Od1lWUlhaek5xQ2kwdExTMHRSVTVFSUVORlVsUkpSa2xEUVZSRkxTMHRMUzBLIn19fX0=",
        "integratedTime": 1656448131,
        "logID": "d32f30a3c32d639c2b762205a21c7bb07788e68283a4ae6f42118723a1bea496",
        "logIndex": 1688,
        "verification": {
        "inclusionProof": {
            "hashes": [
            "810320ec3029914695826d60133c67021f66ee0cfb09a6f79eb267ed9f55de2c",
            "67e9d9f66f0ad388f7e1a20991e9a2ae3efad5cbf281e8b3d2aaf1ef99a4618c",
            "16a106400c53465f6e18c2475df6ba889ca30f5667bacf32b1a5661f14a5080c",
            "b4439e8d71edbc96271723cb7a969dd725e23e73d139361864a62ed76ce8dc11",
            "49b3e90806c7b63b5a86f5748e3ecb7d264ea0828eb74a45bc1a2cd7962408e8",
            "5059ad9b48fa50bd9adcbff0dd81c5a0dcb60f37e0716e723a33805a464f72f8",
            "6c2ce64219799e61d72996884eee9e19fb906e4d7fa04b71625fde4108f21762",
            "784f79c817abb78db3ae99b6c1ede640470bf4bb678673a05bf3a6b50aaaddd6",
            "c6d92ebf4e10cdba500ca410166cd0a8d8b312154d2f45bc4292d63dea6112f6",
            "1768732027401f6718b0df7769e2803127cfc099eb130a8ed7d913218f6a65f6",
            "0da021f68571b65e49e926e4c69024de3ac248a1319d254bc51a85a657b93c33",
            "bc8cf0c8497d5c24841de0c9bef598ec99bbd59d9538d58568340646fe289e9a",
            "be328fa737b8fa9461850b8034250f237ff5b0b590b9468e6223968df294872b",
            "6f06f4025d0346f04830352b23f65c8cd9e3ce4b8cb899877c35282521ddaf85"
            ],
            "logIndex": 1227,
            "rootHash": "effa4fa4575f72829016a64e584441203de533212f9470d63a56d1992e73465d",
            "treeSize": 14358,
            "checkpoint": "rekor.sigstage.dev - 108574341321668964\n14358\n7/pPpFdfcoKQFqZOWERBID3lMyEvlHDWOlbRmS5zRl0=\n\n— rekor.sigstage.dev 0y8wozBFAiB8OkuzdwlL6/rDEu2CsIfqmesaH/KLfmIMvlH3YTdIYgIhAPFZeXK6+b0vbWy4GSU/YZxiTpFrrzjsVOShN4LlPdZb\n"
        },
        "signedEntryTimestamp": "MEUCIQCO8dFvolJwFZDHkhkSdsW3Ny+07fG8CF7G32feG8NJMgIgd2qfJ5shezuXX8I1S6DsudvIZ8xN/+y95at/V5xHfEQ="
        }
    }
    "#;
    /// Pubkey for `rekor.sigstage.dev`.
    const REKOR_STAGING_KEY_PEM: &str = r#"
        -----BEGIN PUBLIC KEY-----
        MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDODRU688UYGuy54mNUlaEBiQdTE9
        nYLr0lg6RXowI/QV/RE1azBn4Eg5/2uTOMbhB1/gfcHzijzFi9Tk+g1Prg==
        -----END PUBLIC KEY-----
    "#;

    #[test]
    fn test_inclusion_proof_valid() {
        let entry = LogEntry::from_str(LOG_ENTRY).expect("failed to parse log entry");
        let rekor_key = CosignVerificationKey::from_pem(
            REKOR_STAGING_KEY_PEM.as_bytes(),
            &SigningScheme::ECDSA_P256_SHA256_ASN1,
        )
        .expect("failed to parse Rekor key");
        entry
            .verify_inclusion(&rekor_key)
            .expect("rejected valid inclusion proof");
    }

    #[test]
    fn test_inclusion_proof_missing_proof() {
        let mut entry = LogEntry::from_str(LOG_ENTRY).expect("failed to parse log entry");
        entry.verification.inclusion_proof = None;
        let rekor_key = CosignVerificationKey::from_pem(
            REKOR_STAGING_KEY_PEM.as_bytes(),
            &SigningScheme::ECDSA_P256_SHA256_ASN1,
        )
        .expect("failed to parse Rekor key");
        entry
            .verify_inclusion(&rekor_key)
            .expect_err("accepted invalid inclusion proof");
    }

    #[test]
    fn test_inclusion_proof_modified_proof() {
        let entry = LogEntry::from_str(LOG_ENTRY).expect("failed to parse log entry");
        let rekor_key = CosignVerificationKey::from_pem(
            REKOR_STAGING_KEY_PEM.as_bytes(),
            &SigningScheme::ECDSA_P256_SHA256_ASN1,
        )
        .expect("failed to parse Rekor key");

        let mut test_cases = vec![];

        // swap upper and lower halves of a hash.
        let mut entry_modified_hashes = entry.clone();
        entry_modified_hashes
            .verification
            .inclusion_proof
            .as_mut()
            .unwrap()
            .hashes[0] =
            "1f66ee0cfb09a6f79eb267ed9f55de2c810320ec3029914695826d60133c6702".to_string();
        test_cases.push((entry_modified_hashes, "modified hash"));

        // modify checkpoint.
        let mut entry_modified_checkpoint = entry.clone();
        entry_modified_checkpoint
            .verification
            .inclusion_proof
            .as_mut()
            .unwrap()
            .checkpoint = "foo".to_string();
        test_cases.push((entry_modified_checkpoint, "modified checkpoint"));

        // modify log index.
        let mut entry_modified_log_index = entry.clone();
        entry_modified_log_index
            .verification
            .inclusion_proof
            .as_mut()
            .unwrap()
            .log_index += 1;
        test_cases.push((entry_modified_log_index, "modified log index"));

        // modify root hash.
        let mut entry_modified_root_hash = entry.clone();
        entry_modified_root_hash
            .verification
            .inclusion_proof
            .as_mut()
            .unwrap()
            .root_hash =
            "3de533212f9470d63a56d1992e73465deffa4fa4575f72829016a64e58444120".to_string();
        test_cases.push((entry_modified_root_hash, "modified root hash"));

        // modify tree size.
        let mut entry_modified_tree_size = entry.clone();
        entry_modified_tree_size
            .verification
            .inclusion_proof
            .as_mut()
            .unwrap()
            .tree_size += 1;
        test_cases.push((entry_modified_tree_size, "modified tree size"));

        for (case, desc) in test_cases {
            let res = case.verify_inclusion(&rekor_key);
            assert!(res.is_err(), "accepted invalid proof: {desc}");
        }
    }
}