use std::time::Duration as StdDuration;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine as _;
use chrono::Utc;
use p256::ecdsa::signature::Verifier as _;
use p256::ecdsa::{DerSignature, Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, warn};
use crate::anchor::LedgerAnchor;
use crate::external_sink::trusted_root::{TrustedRoot, TrustedRootKeyError};
use crate::external_sink::{anchor_text_sha256, ExternalReceipt, ExternalSink};
use crate::sha256::sha256;
pub const REKOR_KIND_HASHEDREKORD_V0_0_1: &str = "hashedrekord:0.0.1";
pub const REKOR_DEFAULT_ENDPOINT: &str = "https://rekor.sigstore.dev";
const REKOR_ENTRIES_PATH: &str = "/api/v1/log/entries";
const REKOR_HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(20);
pub const REKOR_SUBMIT_FAILED_INVARIANT: &str = "audit.anchor.rekor.submit_failed";
pub const REKOR_VERIFY_FAILED_INVARIANT: &str = "audit.anchor.rekor.verify_failed";
pub const REKOR_INCLUSION_PROOF_INVALID_INVARIANT: &str =
"audit.anchor.rekor.inclusion_proof_invalid";
pub const REKOR_SET_SIGNATURE_INVALID_INVARIANT: &str = "audit.anchor.rekor.set_signature_invalid";
pub const REKOR_TRUSTED_ROOT_STALE_INVARIANT: &str = "audit.anchor.rekor.trusted_root_stale";
pub const REKOR_VERIFY_SIGNATURE_MISMATCH_INVARIANT: &str = "audit.verify.rekor.signature_mismatch";
pub const REKOR_EXTERNAL_AUTHORITY_STATUS: &str = "external_authority_rekor";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InclusionProof {
#[serde(rename = "logIndex")]
pub log_index: u64,
#[serde(rename = "treeSize")]
pub tree_size: u64,
#[serde(rename = "rootHash")]
pub root_hash: String,
pub hashes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RekorReceiptBody {
pub uuid: String,
#[serde(rename = "logID")]
pub log_id: String,
#[serde(rename = "logIndex")]
pub log_index: u64,
#[serde(rename = "integratedTime")]
pub integrated_time: i64,
#[serde(rename = "signedEntryTimestamp")]
pub signed_entry_timestamp: String,
#[serde(rename = "body")]
pub body_base64: String,
#[serde(rename = "inclusionProof")]
pub inclusion_proof: InclusionProof,
}
#[derive(Debug, Serialize)]
struct RekorSubmitRequest<'a> {
#[serde(rename = "apiVersion")]
api_version: &'a str,
kind: &'a str,
spec: RekorSubmitSpec<'a>,
}
#[derive(Debug, Serialize)]
struct RekorSubmitSpec<'a> {
data: RekorSubmitData<'a>,
}
#[derive(Debug, Serialize)]
struct RekorSubmitData<'a> {
hash: RekorSubmitHash<'a>,
}
#[derive(Debug, Serialize)]
struct RekorSubmitHash<'a> {
algorithm: &'a str,
value: &'a str,
}
pub fn submit(anchor: &LedgerAnchor, endpoint: &str) -> Result<ExternalReceipt, RekorError> {
let anchor_text = anchor.to_anchor_text();
let anchor_hash = sha256(anchor_text.as_bytes());
let anchor_hash_hex = lowercase_hex(&anchor_hash);
let request = RekorSubmitRequest {
api_version: "0.0.1",
kind: REKOR_KIND_HASHEDREKORD_V0_0_1,
spec: RekorSubmitSpec {
data: RekorSubmitData {
hash: RekorSubmitHash {
algorithm: "sha256",
value: &anchor_hash_hex,
},
},
},
};
let url = format!("{}{REKOR_ENTRIES_PATH}", endpoint.trim_end_matches('/'));
debug!(target: "cortex.audit.anchor.rekor", endpoint = %url, "submitting Rekor entry");
let agent = ureq::AgentBuilder::new()
.timeout(REKOR_HTTP_TIMEOUT)
.build();
let response = agent
.post(&url)
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&request).expect("submit request serializes"))
.map_err(|source| {
warn!(
target: "cortex.audit.anchor.rekor",
invariant = REKOR_SUBMIT_FAILED_INVARIANT,
error = %source,
"Rekor submission failed"
);
RekorError::SubmitHttp {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: source.to_string(),
}
})?;
let response_text = response
.into_string()
.map_err(|source| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: format!("failed to read Rekor response body: {source}"),
})?;
let receipt = parse_rekor_submit_response(&response_text)?;
let anchor_text_sha256_hex = anchor_text_sha256(anchor);
let envelope = ExternalReceipt {
sink: ExternalSink::Rekor,
anchor_text_sha256: anchor_text_sha256_hex,
anchor_event_count: anchor.event_count,
anchor_chain_head_hash: anchor.chain_head_hash.clone(),
submitted_at: Utc::now(),
sink_endpoint: endpoint.to_string(),
receipt: serde_json::to_value(&receipt).map_err(|source| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: format!("failed to re-encode Rekor receipt body: {source}"),
})?,
};
Ok(envelope)
}
fn parse_rekor_submit_response(text: &str) -> Result<RekorReceiptBody, RekorError> {
let parsed: serde_json::Value =
serde_json::from_str(text).map_err(|source| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: format!("Rekor response is not valid JSON: {source}"),
})?;
let entry_map = parsed.as_object().ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response is not a JSON object".to_string(),
})?;
let (uuid, entry) = entry_map
.iter()
.next()
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response did not contain any entry".to_string(),
})?;
let body_base64 = entry
.get("body")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing string body".to_string(),
})?
.to_string();
let log_id = entry
.get("logID")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing logID".to_string(),
})?
.to_string();
let log_index = entry
.get("logIndex")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing logIndex".to_string(),
})?;
let integrated_time = entry
.get("integratedTime")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing integratedTime".to_string(),
})?;
let signed_entry_timestamp = entry
.pointer("/verification/signedEntryTimestamp")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing verification.signedEntryTimestamp".to_string(),
})?
.to_string();
let inclusion_value = entry
.pointer("/verification/inclusionProof")
.ok_or_else(|| RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: "Rekor response entry missing verification.inclusionProof".to_string(),
})?;
let inclusion_proof: InclusionProof =
serde_json::from_value(inclusion_value.clone()).map_err(|source| {
RekorError::SubmitBody {
invariant: REKOR_SUBMIT_FAILED_INVARIANT,
reason: format!("Rekor response inclusionProof did not parse: {source}"),
}
})?;
Ok(RekorReceiptBody {
uuid: uuid.clone(),
log_id,
log_index,
integrated_time,
signed_entry_timestamp,
body_base64,
inclusion_proof,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RekorVerification {
pub log_index: u64,
pub uuid: String,
pub set_signature: String,
}
pub fn verify_receipt(
receipt: &ExternalReceipt,
trusted_root: &TrustedRoot,
) -> Result<RekorVerification, RekorError> {
if receipt.sink != ExternalSink::Rekor {
return Err(RekorError::WrongSink {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
observed: receipt.sink,
});
}
let body: RekorReceiptBody =
serde_json::from_value(receipt.receipt.clone()).map_err(|source| {
RekorError::MalformedReceipt {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!("Rekor receipt body did not parse: {source}"),
}
})?;
let decoded_body = BASE64_STANDARD
.decode(body.body_base64.as_bytes())
.map_err(|source| RekorError::MalformedReceipt {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!("Rekor receipt body is not valid base64: {source}"),
})?;
let body_json: serde_json::Value =
serde_json::from_slice(&decoded_body).map_err(|source| RekorError::MalformedReceipt {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!("Rekor receipt body is not JSON: {source}"),
})?;
let kind = body_json
.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if kind != REKOR_KIND_HASHEDREKORD_V0_0_1 {
return Err(RekorError::MalformedReceipt {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!(
"Rekor receipt body kind `{kind}` is not `{REKOR_KIND_HASHEDREKORD_V0_0_1}`"
),
});
}
let rekor_key =
trusted_root
.rekor_verifying_key(&body.log_id)
.map_err(|source| match source {
TrustedRootKeyError::TlogLogIdNoMatch {
invariant,
receipt_log_id,
tlog_log_ids,
} => RekorError::TlogLogIdUnknown {
invariant,
receipt_log_id,
tlog_log_ids,
},
other => RekorError::MalformedReceipt {
invariant: REKOR_VERIFY_FAILED_INVARIANT,
reason: format!("trusted_root has no usable Rekor verifying key: {other}"),
},
})?;
verify_set_signature(&body, &rekor_key)?;
verify_inclusion_proof(&body, &decoded_body)?;
Ok(RekorVerification {
log_index: body.log_index,
uuid: body.uuid,
set_signature: body.signed_entry_timestamp,
})
}
#[must_use]
pub fn rekor_canonical_set_body(
log_id: &str,
log_index: u64,
integrated_time: i64,
body_base64: &str,
) -> String {
let mut out = String::with_capacity(64 + body_base64.len() + log_id.len());
out.push('{');
out.push_str("\"body\":\"");
out.push_str(body_base64);
out.push_str("\",\"integratedTime\":");
out.push_str(&integrated_time.to_string());
out.push_str(",\"logID\":\"");
out.push_str(log_id);
out.push_str("\",\"logIndex\":");
out.push_str(&log_index.to_string());
out.push('}');
out
}
fn verify_set_signature(
body: &RekorReceiptBody,
verifying_key: &VerifyingKey,
) -> Result<(), RekorError> {
let canonical = rekor_canonical_set_body(
&body.log_id,
body.log_index,
body.integrated_time,
&body.body_base64,
);
let signature_bytes = BASE64_STANDARD
.decode(body.signed_entry_timestamp.as_bytes())
.map_err(|source| RekorError::SetSignatureInvalid {
invariant: REKOR_SET_SIGNATURE_INVALID_INVARIANT,
reason: format!("signedEntryTimestamp is not valid base64: {source}"),
})?;
let der_sig = DerSignature::from_bytes(&signature_bytes).map_err(|source| {
RekorError::SetSignatureInvalid {
invariant: REKOR_SET_SIGNATURE_INVALID_INVARIANT,
reason: format!("signedEntryTimestamp is not valid ECDSA DER: {source}"),
}
})?;
let signature: Signature =
der_sig
.try_into()
.map_err(|source: ecdsa::Error| RekorError::SetSignatureInvalid {
invariant: REKOR_SET_SIGNATURE_INVALID_INVARIANT,
reason: format!("signedEntryTimestamp DER->fixed-size conversion failed: {source}"),
})?;
verifying_key
.verify(canonical.as_bytes(), &signature)
.map_err(|source| {
warn!(
target: "cortex.audit.anchor.rekor",
invariant = REKOR_SET_SIGNATURE_INVALID_INVARIANT,
error = %source,
"Rekor SET signature verification failed"
);
RekorError::SetSignatureInvalid {
invariant: REKOR_SET_SIGNATURE_INVALID_INVARIANT,
reason: source.to_string(),
}
})?;
Ok(())
}
fn verify_inclusion_proof(body: &RekorReceiptBody, decoded_leaf: &[u8]) -> Result<(), RekorError> {
let mut current = sha256(decoded_leaf);
let mut index = body.inclusion_proof.log_index;
if index >= body.inclusion_proof.tree_size {
return Err(RekorError::InclusionProofInvalid {
invariant: REKOR_INCLUSION_PROOF_INVALID_INVARIANT,
reason: format!(
"logIndex {index} is not strictly less than treeSize {}",
body.inclusion_proof.tree_size
),
});
}
let mut bound = body.inclusion_proof.tree_size;
for sibling_hex in &body.inclusion_proof.hashes {
let sibling =
decode_hex_32(sibling_hex).map_err(|reason| RekorError::InclusionProofInvalid {
invariant: REKOR_INCLUSION_PROOF_INVALID_INVARIANT,
reason,
})?;
let combined = if index % 2 == 0 {
concat_hash(¤t, &sibling)
} else {
concat_hash(&sibling, ¤t)
};
current = combined;
index /= 2;
bound = bound.div_ceil(2);
if bound == 0 {
break;
}
}
let expected_root = decode_hex_32(&body.inclusion_proof.root_hash).map_err(|reason| {
RekorError::InclusionProofInvalid {
invariant: REKOR_INCLUSION_PROOF_INVALID_INVARIANT,
reason,
}
})?;
if current != expected_root {
warn!(
target: "cortex.audit.anchor.rekor",
invariant = REKOR_INCLUSION_PROOF_INVALID_INVARIANT,
"Rekor inclusion proof did not reconstruct rootHash"
);
return Err(RekorError::InclusionProofInvalid {
invariant: REKOR_INCLUSION_PROOF_INVALID_INVARIANT,
reason: format!(
"reconstructed root `{}` does not match declared root `{}`",
lowercase_hex(¤t),
body.inclusion_proof.root_hash
),
});
}
Ok(())
}
fn concat_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut buf = [0u8; 64];
buf[..32].copy_from_slice(left);
buf[32..].copy_from_slice(right);
sha256(&buf)
}
fn decode_hex_32(value: &str) -> Result<[u8; 32], String> {
if value.len() != 64 {
return Err(format!(
"expected 64 lowercase hex chars in proof element, got {}",
value.len()
));
}
let bytes = value.as_bytes();
let mut out = [0u8; 32];
for (i, chunk) in bytes.chunks_exact(2).enumerate() {
let hi = decode_hex_nibble(chunk[0])
.ok_or_else(|| format!("non-hex character at position {}", i * 2))?;
let lo = decode_hex_nibble(chunk[1])
.ok_or_else(|| format!("non-hex character at position {}", i * 2 + 1))?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn decode_hex_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + b - b'a'),
_ => None,
}
}
fn lowercase_hex(bytes: &[u8; 32]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[derive(Debug, Error)]
pub enum RekorError {
#[error("{invariant}: Rekor HTTP submission failed: {reason}")]
SubmitHttp {
invariant: &'static str,
reason: String,
},
#[error("{invariant}: Rekor response body did not parse: {reason}")]
SubmitBody {
invariant: &'static str,
reason: String,
},
#[error("{invariant}: external receipt sink is `{observed}`, expected `rekor`")]
WrongSink {
invariant: &'static str,
observed: ExternalSink,
},
#[error("{invariant}: Rekor receipt is malformed: {reason}")]
MalformedReceipt {
invariant: &'static str,
reason: String,
},
#[error("{invariant}: Rekor SignedEntryTimestamp did not verify: {reason}")]
SetSignatureInvalid {
invariant: &'static str,
reason: String,
},
#[error("{invariant}: Rekor inclusion proof did not verify: {reason}")]
InclusionProofInvalid {
invariant: &'static str,
reason: String,
},
#[error("{invariant}: trusted-root cache is stale (signed_at={signed_at_rfc3339})")]
TrustedRootStale {
invariant: &'static str,
signed_at_rfc3339: String,
},
#[error(
"{invariant}: trusted_root has no tlog whose logId.keyId matches Rekor receipt logID `{receipt_log_id}` (declared tlogs: {})",
tlog_log_ids.join(", ")
)]
TlogLogIdUnknown {
invariant: &'static str,
receipt_log_id: String,
tlog_log_ids: Vec<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::external_sink::trusted_root::TrustedRoot;
use crate::external_sink::{parse_external_receipt, EXTERNAL_RECEIPT_FORMAT_HEADER_V1};
fn valid_fixture_text() -> &'static str {
include_str!("../../../cortex-cli/tests/fixtures/rekor/rekor_receipt_valid.json")
}
fn tampered_set_fixture_text() -> &'static str {
include_str!("../../../cortex-cli/tests/fixtures/rekor/rekor_receipt_tampered_set.json")
}
fn tampered_proof_fixture_text() -> &'static str {
include_str!("../../../cortex-cli/tests/fixtures/rekor/rekor_receipt_tampered_proof.json")
}
const FIXTURE_REKOR_LOG_ID: &str = "fixture-log-id";
fn fixture_trust_root() -> TrustedRoot {
use chrono::TimeZone;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureSnapshot {
#[allow(dead_code)]
format_version: u32,
rekor_public_key_pem: String,
signed_at: chrono::DateTime<chrono::Utc>,
}
let raw =
include_str!("../../../cortex-cli/tests/fixtures/rekor/trusted_root_snapshot.json");
let snapshot: FixtureSnapshot =
serde_json::from_str(raw).expect("fixture trust root parses");
let _ = chrono::Utc.timestamp_opt(0, 0); TrustedRoot::from_fixture_rekor_pem(
&snapshot.rekor_public_key_pem,
snapshot.signed_at,
FIXTURE_REKOR_LOG_ID,
)
.expect("fixture Rekor PEM decodes")
}
#[test]
fn canonical_set_body_is_stable_byte_order() {
let canonical = rekor_canonical_set_body("log-1", 7, 1_700_000_000, "Ym9keQ==");
assert_eq!(
canonical,
"{\"body\":\"Ym9keQ==\",\"integratedTime\":1700000000,\"logID\":\"log-1\",\"logIndex\":7}"
);
}
#[test]
fn verify_receipt_accepts_well_formed_fixture() {
let receipt = parse_external_receipt(valid_fixture_text()).expect("fixture parses");
let trusted_root = fixture_trust_root();
let verification = verify_receipt(&receipt, &trusted_root).expect("verifies");
assert_eq!(verification.log_index, 2);
}
#[test]
fn verify_receipt_rejects_tampered_set_signature() {
let receipt = parse_external_receipt(tampered_set_fixture_text()).expect("fixture parses");
let trusted_root = fixture_trust_root();
let err = verify_receipt(&receipt, &trusted_root).unwrap_err();
assert!(
matches!(err, RekorError::SetSignatureInvalid { invariant, .. } if invariant == REKOR_SET_SIGNATURE_INVALID_INVARIANT),
"got {err:?}"
);
}
#[test]
fn verify_receipt_rejects_tampered_inclusion_proof() {
let receipt =
parse_external_receipt(tampered_proof_fixture_text()).expect("fixture parses");
let trusted_root = fixture_trust_root();
let err = verify_receipt(&receipt, &trusted_root).unwrap_err();
assert!(
matches!(err, RekorError::InclusionProofInvalid { invariant, .. } if invariant == REKOR_INCLUSION_PROOF_INVALID_INVARIANT),
"got {err:?}"
);
}
#[test]
fn verify_receipt_rejects_wrong_sink() {
let mut receipt = parse_external_receipt(valid_fixture_text()).expect("fixture parses");
receipt.sink = ExternalSink::OpenTimestamps;
let trusted_root = fixture_trust_root();
let err = verify_receipt(&receipt, &trusted_root).unwrap_err();
assert!(matches!(err, RekorError::WrongSink { .. }));
}
#[test]
fn verify_receipt_rejects_receipt_whose_logid_does_not_bind_to_any_tlog() {
let receipt = parse_external_receipt(valid_fixture_text()).expect("fixture parses");
use chrono::TimeZone;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureSnapshot {
#[allow(dead_code)]
format_version: u32,
rekor_public_key_pem: String,
signed_at: chrono::DateTime<chrono::Utc>,
}
let raw =
include_str!("../../../cortex-cli/tests/fixtures/rekor/trusted_root_snapshot.json");
let snapshot: FixtureSnapshot =
serde_json::from_str(raw).expect("fixture trust root parses");
let _ = chrono::Utc.timestamp_opt(0, 0);
let trusted_root = TrustedRoot::from_fixture_rekor_pem(
&snapshot.rekor_public_key_pem,
snapshot.signed_at,
"wrong-log-id-not-the-fixture",
)
.expect("fixture Rekor PEM decodes");
let err = verify_receipt(&receipt, &trusted_root).unwrap_err();
match err {
RekorError::TlogLogIdUnknown {
invariant,
receipt_log_id,
..
} => {
assert_eq!(invariant, "rekor.trusted_root.tlog_logid_no_match");
assert_eq!(receipt_log_id, "fixture-log-id");
}
other => panic!("expected TlogLogIdUnknown, got {other:?}"),
}
}
#[test]
fn fixture_header_is_v1() {
assert!(valid_fixture_text().starts_with(EXTERNAL_RECEIPT_FORMAT_HEADER_V1));
}
#[test]
#[ignore = "requires CORTEX_REKOR_LIVE=1; live network call against Sigstore Rekor"]
fn submit_against_live_rekor_when_env_gated() {
if std::env::var("CORTEX_REKOR_LIVE").ok().as_deref() != Some("1") {
eprintln!("skipping live Rekor submission test; set CORTEX_REKOR_LIVE=1 to opt in");
return;
}
let anchor = LedgerAnchor::new(Utc::now(), 1, "a".repeat(64)).expect("anchor");
let envelope = submit(&anchor, REKOR_DEFAULT_ENDPOINT).expect("live Rekor submission");
assert_eq!(envelope.sink, ExternalSink::Rekor);
}
}