use crate::error::WSError;
use crate::signature::keyless::fulcio::FulcioCertificate;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const REKOR_SKIPPED_UUID: &str = "skipped";
pub fn is_rekor_skipped(entry: &RekorEntry) -> bool {
entry.uuid.is_empty() || entry.uuid == REKOR_SKIPPED_UUID
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RekorEntry {
pub uuid: String,
pub log_index: u64,
pub body: String,
pub log_id: String,
pub inclusion_proof: Vec<u8>,
pub signed_entry_timestamp: String,
pub integrated_time: String,
}
#[derive(Debug, Serialize)]
struct RekorUploadRequest {
kind: String,
#[serde(rename = "apiVersion")]
api_version: String,
spec: RekorSpec,
}
#[derive(Debug, Serialize)]
struct RekorSpec {
signature: RekorSignature,
data: RekorData,
}
#[derive(Debug, Serialize)]
struct RekorSignature {
content: String,
#[serde(rename = "publicKey")]
public_key: RekorPublicKey,
}
#[derive(Debug, Serialize)]
struct RekorPublicKey {
content: String,
}
#[derive(Debug, Serialize)]
struct RekorData {
hash: RekorHash,
}
#[derive(Debug, Serialize)]
struct RekorHash {
algorithm: String,
value: String,
}
#[derive(Debug, Deserialize)]
struct RekorUploadResponse {
#[serde(flatten)]
entries: HashMap<String, RekorEntryResponse>,
}
#[derive(Debug, Deserialize)]
struct RekorEntryResponse {
#[serde(rename = "logIndex")]
log_index: u64,
#[allow(dead_code)]
body: String, #[serde(rename = "integratedTime")]
integrated_time: i64,
#[serde(rename = "logID")]
#[allow(dead_code)]
log_id: String, verification: Option<RekorVerification>,
}
#[derive(Debug, Deserialize)]
struct RekorVerification {
#[serde(rename = "inclusionProof")]
inclusion_proof: Option<RekorInclusionProof>,
#[serde(rename = "signedEntryTimestamp")]
signed_entry_timestamp: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct RekorInclusionProof {
hashes: Vec<String>,
#[serde(rename = "logIndex")]
log_index: u64,
#[serde(rename = "rootHash")]
root_hash: String,
#[serde(rename = "treeSize")]
tree_size: u64,
}
pub struct RekorClient {
base_url: String,
#[cfg(not(target_os = "wasi"))]
client: ureq::Agent,
}
impl RekorClient {
pub fn new() -> Result<Self, WSError> {
Self::with_url("https://rekor.sigstore.dev".to_string())
}
pub fn with_url(base_url: String) -> Result<Self, WSError> {
#[cfg(not(target_os = "wasi"))]
{
use super::transport::create_agent_with_optional_pinning;
use super::cert_pinning::PinningConfig;
let agent = create_agent_with_optional_pinning(Some(PinningConfig::rekor()))?;
Ok(Self {
base_url,
client: agent,
})
}
#[cfg(target_os = "wasi")]
{
Ok(Self { base_url })
}
}
pub fn upload_entry(
&self,
artifact_hash: &[u8],
signature: &[u8],
certificate: &FulcioCertificate,
) -> Result<RekorEntry, WSError> {
if artifact_hash.len() != 32 {
return Err(WSError::RekorError(
"Artifact hash must be 32 bytes (SHA-256 for ECDSA)".to_string(),
));
}
let hash_hex = artifact_hash
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
let signature_b64 = BASE64.encode(signature);
let cert_chain_pem = certificate.cert_chain.join("\n");
let cert_b64 = BASE64.encode(cert_chain_pem.as_bytes());
let request = RekorUploadRequest {
kind: "hashedrekord".to_string(),
api_version: "0.0.1".to_string(),
spec: RekorSpec {
signature: RekorSignature {
content: signature_b64,
public_key: RekorPublicKey { content: cert_b64 },
},
data: RekorData {
hash: RekorHash {
algorithm: "sha256".to_string(),
value: hash_hex,
},
},
},
};
#[cfg(not(target_os = "wasi"))]
{
self.upload_entry_native(request)
}
#[cfg(target_os = "wasi")]
{
self.upload_entry_wasi(request)
}
}
#[cfg(not(target_os = "wasi"))]
fn upload_entry_native(&self, request: RekorUploadRequest) -> Result<RekorEntry, WSError> {
let url = format!("{}/api/v1/log/entries", self.base_url);
let json_request = serde_json::to_string(&request)
.map_err(|e| WSError::RekorError(format!("Failed to serialize request: {}", e)))?;
let response = self
.client
.post(&url)
.header("Content-Type", "application/json")
.send(json_request.as_bytes())
.map_err(|e| WSError::RekorError(format!("Failed to upload entry: {}", e)))?;
let status = response.status();
if status != 201 {
let error_text = response
.into_body()
.read_to_string()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(WSError::RekorError(format!(
"Upload failed with status {}: {}",
status, error_text
)));
}
let body = response
.into_body()
.read_to_string()
.map_err(|e| WSError::RekorError(format!("Failed to read response body: {}", e)))?;
build_rekor_entry_from_response(&body)
}
#[cfg(target_os = "wasi")]
fn upload_entry_wasi(&self, request: RekorUploadRequest) -> Result<RekorEntry, WSError> {
use wasi::http::outgoing_handler;
use wasi::http::types::{Fields, Method, OutgoingBody, OutgoingRequest, Scheme};
use wasi::io::streams::StreamError;
let url = format!("{}/api/v1/log/entries", self.base_url);
let url_parts: Vec<&str> = url.split("://").collect();
if url_parts.len() != 2 {
return Err(WSError::RekorError("Invalid URL format".to_string()));
}
let scheme = if url_parts[0] == "https" {
Scheme::Https
} else {
Scheme::Http
};
let remaining: Vec<&str> = url_parts[1].splitn(2, '/').collect();
let authority = remaining[0].to_string();
let path_and_query = if remaining.len() > 1 {
format!("/{}", remaining[1])
} else {
"/".to_string()
};
let headers = Fields::new();
headers.set(&"Content-Type".to_string(), &[b"application/json".to_vec()]);
let outgoing_request = OutgoingRequest::new(headers);
outgoing_request.set_scheme(Some(&scheme));
outgoing_request.set_authority(Some(&authority));
outgoing_request.set_path_with_query(Some(&path_and_query));
outgoing_request.set_method(&Method::Post);
let request_json = serde_json::to_vec(&request)
.map_err(|e| WSError::RekorError(format!("Failed to serialize request: {}", e)))?;
let outgoing_body = outgoing_request
.body()
.map_err(|_| WSError::RekorError("Failed to get request body".to_string()))?;
{
let outgoing_stream = outgoing_body
.write()
.map_err(|_| WSError::RekorError("Failed to write request body".to_string()))?;
outgoing_stream
.blocking_write_and_flush(&request_json)
.map_err(|e| WSError::RekorError(format!("Failed to send request: {:?}", e)))?;
}
OutgoingBody::finish(outgoing_body, None)
.map_err(|_| WSError::RekorError("Failed to finish request body".to_string()))?;
let future_response = outgoing_handler::handle(outgoing_request, None)
.map_err(|_| WSError::RekorError("Failed to send HTTP request".to_string()))?;
let incoming_response = future_response
.get()
.ok_or_else(|| WSError::RekorError("No response received".to_string()))?
.map_err(|_| WSError::RekorError("Request failed".to_string()))??;
let status = incoming_response.status();
if status != 201 {
return Err(WSError::RekorError(format!(
"Upload failed with status {}",
status
)));
}
let incoming_body = incoming_response
.consume()
.map_err(|_| WSError::RekorError("Failed to get response body".to_string()))?;
let incoming_stream = incoming_body
.stream()
.map_err(|_| WSError::RekorError("Failed to get response stream".to_string()))?;
let mut response_bytes = Vec::new();
loop {
match incoming_stream.blocking_read(4096) {
Ok(chunk) => {
if chunk.is_empty() {
break;
}
response_bytes.extend_from_slice(&chunk);
}
Err(StreamError::Closed) => break,
Err(e) => {
return Err(WSError::RekorError(format!("Stream error: {:?}", e)));
}
}
}
let body_str = std::str::from_utf8(&response_bytes)
.map_err(|e| WSError::RekorError(format!("Invalid UTF-8 in response: {}", e)))?;
build_rekor_entry_from_response(body_str)
}
pub fn verify_inclusion(&self, entry: &RekorEntry) -> Result<bool, WSError> {
use super::rekor_verifier::RekorKeyring;
let keyring = RekorKeyring::from_embedded_trust_root()
.map_err(|e| WSError::RekorError(format!("Failed to load Rekor keys: {}", e)))?;
keyring.verify_entry(entry)?;
Ok(true)
}
}
fn build_rekor_entry_from_response(body: &str) -> Result<RekorEntry, WSError> {
let response_data: RekorUploadResponse = serde_json::from_str(body)
.map_err(|e| WSError::RekorError(format!("Failed to parse response: {}", e)))?;
if response_data.entries.is_empty() {
return Err(WSError::RekorError(
"No entry returned in response".to_string(),
));
}
let (uuid, entry_data) = response_data
.entries
.into_iter()
.next()
.ok_or_else(|| WSError::RekorError("Empty response from Rekor".to_string()))?;
let verification = entry_data.verification.ok_or_else(|| {
WSError::RekorError(
"Rekor response missing 'verification' object — refusing to return entry".to_string(),
)
})?;
let raw_inclusion_proof = verification.inclusion_proof.ok_or_else(|| {
WSError::RekorError(
"Rekor response missing inclusion proof — refusing to return entry".to_string(),
)
})?;
if raw_inclusion_proof.hashes.is_empty() || raw_inclusion_proof.root_hash.is_empty() {
return Err(WSError::RekorError(
"Rekor response has structurally-empty inclusion proof (no hashes or empty root)"
.to_string(),
));
}
let inclusion_proof = serde_json::to_vec(&raw_inclusion_proof)
.map_err(|e| WSError::RekorError(format!("Failed to serialize inclusion proof: {}", e)))?;
let signed_entry_timestamp = match verification.signed_entry_timestamp {
Some(set) if !set.is_empty() => set,
_ => {
return Err(WSError::RekorError(
"Rekor response missing or empty signed entry timestamp".to_string(),
));
}
};
Ok(RekorEntry {
uuid,
log_index: entry_data.log_index,
body: entry_data.body,
log_id: entry_data.log_id,
inclusion_proof,
signed_entry_timestamp,
integrated_time: format_timestamp(entry_data.integrated_time),
})
}
fn format_timestamp(timestamp: i64) -> String {
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
match OffsetDateTime::from_unix_timestamp(timestamp) {
Ok(dt) => dt
.format(&Rfc3339)
.unwrap_or_else(|_| timestamp.to_string()),
Err(_) => timestamp.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rekor_client_new() {
let client = RekorClient::new().unwrap();
assert_eq!(client.base_url, "https://rekor.sigstore.dev");
}
#[test]
fn test_rekor_client_with_url() {
let custom_url = "https://custom.rekor.server".to_string();
let client = RekorClient::with_url(custom_url.clone()).unwrap();
assert_eq!(client.base_url, custom_url);
}
#[test]
fn test_format_timestamp() {
let timestamp = 1704067200i64;
let formatted = format_timestamp(timestamp);
assert!(formatted.contains("2024"));
}
#[test]
fn test_rekor_entry_creation() {
let entry = RekorEntry {
uuid: "test-uuid-123".to_string(),
log_index: 42,
body: "eyJ0ZXN0IjoidmFsdWUifQ==".to_string(),
log_id: "test-log-id".to_string(),
inclusion_proof: vec![1, 2, 3, 4],
signed_entry_timestamp: "c2lnbmF0dXJl".to_string(),
integrated_time: "2024-01-01T00:00:00Z".to_string(),
};
assert_eq!(entry.uuid, "test-uuid-123");
assert_eq!(entry.log_index, 42);
assert_eq!(entry.body, "eyJ0ZXN0IjoidmFsdWUifQ==");
assert_eq!(entry.log_id, "test-log-id");
assert_eq!(entry.inclusion_proof, vec![1, 2, 3, 4]);
assert_eq!(entry.signed_entry_timestamp, "c2lnbmF0dXJl");
assert_eq!(entry.integrated_time, "2024-01-01T00:00:00Z");
}
#[test]
fn test_upload_entry_invalid_hash_length() {
let client = RekorClient::new().unwrap();
let cert = FulcioCertificate {
cert_chain: vec![
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string(),
],
leaf_cert: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string(),
public_key: vec![0u8; 65],
};
let invalid_hash = vec![0u8; 64]; let signature = vec![0u8; 64];
let result = client.upload_entry(&invalid_hash, &signature, &cert);
assert!(result.is_err());
if let Err(WSError::RekorError(msg)) = result {
assert!(msg.contains("32 bytes"));
} else {
panic!("Expected RekorError");
}
}
#[test]
fn test_verify_inclusion_rejects_invalid() {
let client = RekorClient::new().unwrap();
let entry = RekorEntry {
uuid: "test-uuid".to_string(),
log_index: 1,
body: "eyJ0ZXN0IjoidmFsdWUifQ==".to_string(),
log_id: "test-log-id".to_string(),
inclusion_proof: vec![],
signed_entry_timestamp: String::new(),
integrated_time: "2024-01-01T00:00:00Z".to_string(),
};
let result = client.verify_inclusion(&entry);
assert!(result.is_err(), "Should reject entry with no SET");
}
#[test]
fn test_rekor_upload_request_serialization() {
let request = RekorUploadRequest {
kind: "hashedrekord".to_string(),
api_version: "0.0.1".to_string(),
spec: RekorSpec {
signature: RekorSignature {
content: "c2lnbmF0dXJl".to_string(), public_key: RekorPublicKey {
content: "Y2VydGlmaWNhdGU=".to_string(), },
},
data: RekorData {
hash: RekorHash {
algorithm: "sha256".to_string(),
value: "abcdef1234567890".to_string(),
},
},
},
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("hashedrekord"));
assert!(json.contains("0.0.1"));
assert!(json.contains("sha256"));
assert!(json.contains("c2lnbmF0dXJl"));
}
#[test]
fn test_rekor_upload_response_deserialization() {
let json = r#"{
"24296fb24b8ad77a123456789abcdef": {
"logIndex": 12345,
"body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEifQ==",
"integratedTime": 1704067200,
"logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
"verification": {
"inclusionProof": {
"hashes": ["hash1", "hash2"],
"logIndex": 12345,
"rootHash": "root",
"treeSize": 100000
}
}
}
}"#;
let response: RekorUploadResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.entries.len(), 1);
let (uuid, entry) = response.entries.into_iter().next().unwrap();
assert!(uuid.starts_with("24296fb24b8ad77a"));
assert_eq!(entry.log_index, 12345);
assert_eq!(entry.integrated_time, 1704067200);
assert!(entry.verification.is_some());
}
#[test]
fn test_mock_rekor_entry_flow() {
let client = RekorClient::new().unwrap();
let cert = FulcioCertificate {
cert_chain: vec![
"-----BEGIN CERTIFICATE-----\nMIIBkTCCATegAwIBAgIUTest\n-----END CERTIFICATE-----"
.to_string(),
],
leaf_cert:
"-----BEGIN CERTIFICATE-----\nMIIBkTCCATegAwIBAgIUTest\n-----END CERTIFICATE-----"
.to_string(),
public_key: vec![0u8; 65], };
let artifact_hash = vec![0u8; 32];
let signature = vec![0u8; 64];
let result = client.upload_entry(&artifact_hash, &signature, &cert);
assert!(result.is_err());
}
fn full_rekor_response_json() -> String {
r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"inclusionProof": {
"hashes": ["aa", "bb"],
"logIndex": 7,
"rootHash": "cc",
"treeSize": 10
},
"signedEntryTimestamp": "MEUCIQ=="
}
}
}"#
.to_string()
}
#[test]
fn test_h5_full_response_accepted() {
let entry = build_rekor_entry_from_response(&full_rekor_response_json())
.expect("complete response must be accepted");
assert_eq!(entry.uuid, "abc123");
assert_eq!(entry.signed_entry_timestamp, "MEUCIQ==");
assert!(!entry.inclusion_proof.is_empty());
}
#[test]
fn test_h5_empty_set_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"inclusionProof": {
"hashes": ["aa", "bb"],
"logIndex": 7,
"rootHash": "cc",
"treeSize": 10
},
"signedEntryTimestamp": ""
}
}
}"#;
let err = build_rekor_entry_from_response(json).expect_err("empty SET must reject");
match err {
WSError::RekorError(msg) => assert!(
msg.to_lowercase().contains("signed entry timestamp"),
"msg: {}",
msg
),
other => panic!("expected RekorError, got {:?}", other),
}
}
#[test]
fn test_h5_missing_set_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"inclusionProof": {
"hashes": ["aa"],
"logIndex": 7,
"rootHash": "cc",
"treeSize": 10
}
}
}
}"#;
assert!(matches!(
build_rekor_entry_from_response(json),
Err(WSError::RekorError(_))
));
}
#[test]
fn test_h5_missing_inclusion_proof_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"signedEntryTimestamp": "MEUCIQ=="
}
}
}"#;
let err =
build_rekor_entry_from_response(json).expect_err("missing inclusion proof must reject");
match err {
WSError::RekorError(msg) => assert!(msg.contains("inclusion proof"), "msg: {}", msg),
other => panic!("expected RekorError, got {:?}", other),
}
}
#[test]
fn test_h5_empty_inclusion_proof_hashes_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"inclusionProof": {
"hashes": [],
"logIndex": 7,
"rootHash": "cc",
"treeSize": 10
},
"signedEntryTimestamp": "MEUCIQ=="
}
}
}"#;
let err = build_rekor_entry_from_response(json)
.expect_err("structurally empty inclusion proof must reject");
match err {
WSError::RekorError(msg) => assert!(
msg.to_lowercase().contains("structurally-empty"),
"msg: {}",
msg
),
other => panic!("expected RekorError, got {:?}", other),
}
}
#[test]
fn test_h5_empty_root_hash_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef",
"verification": {
"inclusionProof": {
"hashes": ["aa"],
"logIndex": 7,
"rootHash": "",
"treeSize": 10
},
"signedEntryTimestamp": "MEUCIQ=="
}
}
}"#;
assert!(matches!(
build_rekor_entry_from_response(json),
Err(WSError::RekorError(_))
));
}
#[test]
fn test_h5_missing_verification_object_rejected() {
let json = r#"{
"abc123": {
"logIndex": 7,
"body": "ZHVtbXk=",
"integratedTime": 1704067200,
"logID": "deadbeef"
}
}"#;
assert!(matches!(
build_rekor_entry_from_response(json),
Err(WSError::RekorError(_))
));
}
#[test]
fn test_h5_no_entries_rejected() {
let json = r#"{}"#;
assert!(matches!(
build_rekor_entry_from_response(json),
Err(WSError::RekorError(_))
));
}
}