use crate::error::WSError;
use crate::signature::keyless::oidc::OidcToken;
use serde::{Deserialize, Serialize};
use spki::der::asn1::BitString;
use spki::der::{Decode, Encode};
use spki::{AlgorithmIdentifierOwned, ObjectIdentifier, SubjectPublicKeyInfoOwned};
use x509_parser::prelude::*;
#[derive(Debug, Clone)]
pub struct FulcioCertificate {
pub cert_chain: Vec<String>,
pub leaf_cert: String,
pub public_key: Vec<u8>,
}
#[derive(Debug, Serialize)]
struct FulcioRequest {
credentials: Credentials,
#[serde(rename = "publicKeyRequest")]
public_key_request: PublicKeyRequest,
}
#[derive(Debug, Serialize)]
struct Credentials {
#[serde(rename = "oidcIdentityToken")]
oidc_identity_token: String,
}
#[derive(Debug, Serialize)]
struct PublicKeyRequest {
#[serde(rename = "publicKey")]
public_key: PublicKey,
#[serde(rename = "proofOfPossession")]
proof_of_possession: String,
}
#[derive(Debug, Serialize)]
struct PublicKey {
algorithm: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct FulcioResponse {
#[serde(rename = "signedCertificateEmbeddedSct")]
signed_certificate_embedded_sct: SignedCertificateEmbeddedSct,
}
#[derive(Debug, Deserialize)]
struct SignedCertificateEmbeddedSct {
chain: ChainWrapper,
}
#[derive(Debug, Deserialize)]
struct ChainWrapper {
certificates: Vec<String>,
}
pub struct FulcioClient {
base_url: String,
#[cfg(not(target_os = "wasi"))]
client: ureq::Agent,
}
impl FulcioClient {
pub fn new() -> Self {
Self::with_url("https://fulcio.sigstore.dev".to_string())
}
pub fn with_url(base_url: String) -> Self {
#[cfg(not(target_os = "wasi"))]
{
use super::transport::create_agent_with_optional_pinning;
use super::cert_pinning::PinningConfig;
let pinning = Some(PinningConfig::fulcio());
let agent = match create_agent_with_optional_pinning(pinning) {
Ok(agent) => agent,
Err(e) => {
log::error!("Failed to create pinned agent for Fulcio: {}. Using standard TLS.", e);
super::transport::create_standard_agent()
}
};
Self {
base_url,
client: agent,
}
}
#[cfg(target_os = "wasi")]
{
Self { base_url }
}
}
fn encode_ecdsa_p256_spki(raw_public_key: &[u8]) -> Result<Vec<u8>, WSError> {
const EC_PUBLIC_KEY_OID: &str = "1.2.840.10045.2.1";
const SECP256R1_OID: &str = "1.2.840.10045.3.1.7";
let ec_oid = ObjectIdentifier::new(EC_PUBLIC_KEY_OID)
.map_err(|e| WSError::FulcioError(format!("Invalid EC public key OID: {}", e)))?;
let curve_oid = ObjectIdentifier::new(SECP256R1_OID)
.map_err(|e| WSError::FulcioError(format!("Invalid secp256r1 OID: {}", e)))?;
use spki::der::Any;
let curve_oid_der = curve_oid
.to_der()
.map_err(|e| WSError::FulcioError(format!("Failed to encode curve OID: {}", e)))?;
let curve_oid_any = Any::from_der(&curve_oid_der).map_err(|e| {
WSError::FulcioError(format!("Failed to parse curve OID as Any: {}", e))
})?;
let algorithm = AlgorithmIdentifierOwned {
oid: ec_oid,
parameters: Some(curve_oid_any),
};
let public_key_bits = BitString::new(0, raw_public_key)
.map_err(|e| WSError::FulcioError(format!("Failed to create BitString: {}", e)))?;
let spki = SubjectPublicKeyInfoOwned {
algorithm,
subject_public_key: public_key_bits,
};
spki.to_der()
.map_err(|e| WSError::FulcioError(format!("Failed to encode SPKI: {}", e)))
}
fn encode_spki_to_pem(spki_der: &[u8]) -> Result<String, WSError> {
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, spki_der);
let mut pem = String::from("-----BEGIN PUBLIC KEY-----\n");
for chunk in b64.as_bytes().chunks(64) {
let chunk_str = std::str::from_utf8(chunk).map_err(|e| {
WSError::FulcioError(format!("Invalid base64 encoding (not UTF-8): {}", e))
})?;
pem.push_str(chunk_str);
pem.push('\n');
}
pem.push_str("-----END PUBLIC KEY-----");
Ok(pem)
}
pub fn get_certificate(
&self,
oidc_token: &OidcToken,
public_key: &[u8],
proof_of_possession: &[u8],
) -> Result<FulcioCertificate, WSError> {
let spki_der = Self::encode_ecdsa_p256_spki(public_key)?;
let public_key_pem = Self::encode_spki_to_pem(&spki_der)?;
let proof_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
proof_of_possession,
);
let request = FulcioRequest {
credentials: Credentials {
oidc_identity_token: oidc_token.token.clone(),
},
public_key_request: PublicKeyRequest {
public_key: PublicKey {
algorithm: "ECDSA".to_string(),
content: public_key_pem,
},
proof_of_possession: proof_b64,
},
};
let response = self.send_request(&request)?;
let cert_chain = response.signed_certificate_embedded_sct.chain.certificates;
if cert_chain.is_empty() {
return Err(WSError::FulcioError(
"Empty certificate chain in response".to_string(),
));
}
let leaf_cert = cert_chain[0].clone();
let public_key = Self::extract_public_key(&leaf_cert)?;
Ok(FulcioCertificate {
cert_chain: cert_chain.clone(),
leaf_cert,
public_key,
})
}
fn extract_public_key(pem_cert: &str) -> Result<Vec<u8>, WSError> {
let pem = pem::parse(pem_cert)
.map_err(|e| WSError::FulcioError(format!("Failed to parse PEM certificate: {}", e)))?;
let (_, cert) = X509Certificate::from_der(pem.contents()).map_err(|e| {
WSError::FulcioError(format!("Failed to parse X.509 certificate: {}", e))
})?;
let public_key = cert.public_key();
let key_bytes = public_key.subject_public_key.data.to_vec();
Ok(key_bytes)
}
}
#[cfg(not(target_os = "wasi"))]
impl FulcioClient {
fn send_request(&self, request: &FulcioRequest) -> Result<FulcioResponse, WSError> {
let url = format!("{}/api/v2/signingCert", self.base_url);
let json_request = serde_json::to_string(request)
.map_err(|e| WSError::FulcioError(format!("Failed to serialize request: {}", e)))?;
eprintln!("[DEBUG] Fulcio request JSON: {}", json_request);
log::debug!("Fulcio request JSON: {}", json_request);
let response = self
.client
.post(&url)
.header("Content-Type", "application/json")
.send(json_request.as_bytes())
.map_err(|e| {
WSError::FulcioError(format!("Failed to send request to Fulcio: {}", e))
})?;
let status = response.status();
if status != 200 && status != 201 {
let error_body = response
.into_body()
.read_to_string()
.unwrap_or_else(|_| "Unable to read error response".to_string());
return Err(WSError::FulcioError(format!(
"Fulcio returned status {}: {}",
status, error_body
)));
}
let body = response
.into_body()
.read_to_string()
.map_err(|e| WSError::FulcioError(format!("Failed to read response body: {}", e)))?;
let fulcio_response: FulcioResponse = serde_json::from_str(&body)
.map_err(|e| WSError::FulcioError(format!("Failed to parse Fulcio response: {}", e)))?;
Ok(fulcio_response)
}
}
#[cfg(target_os = "wasi")]
impl FulcioClient {
fn send_request(&self, request: &FulcioRequest) -> Result<FulcioResponse, WSError> {
use wasi::http::outgoing_handler;
use wasi::http::types::{Fields, Method, OutgoingBody, OutgoingRequest, Scheme};
let request_json = serde_json::to_vec(request)
.map_err(|e| WSError::FulcioError(format!("Failed to serialize request: {}", e)))?;
let url = format!("{}/api/v2/signingCert", self.base_url);
let url_str = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.ok_or_else(|| WSError::FulcioError("Invalid Fulcio URL scheme".to_string()))?;
let (authority, path) = url_str
.split_once('/')
.map(|(auth, path)| (auth, format!("/{}", path)))
.unwrap_or((url_str, "/api/v2/signingCert".to_string()));
let headers = Fields::new();
headers
.append(&"Content-Type".to_string(), &b"application/json".to_vec())
.map_err(|_| WSError::FulcioError("Failed to set Content-Type header".to_string()))?;
let outgoing_request = OutgoingRequest::new(headers);
outgoing_request
.set_method(&Method::Post)
.map_err(|_| WSError::FulcioError("Failed to set HTTP method".to_string()))?;
outgoing_request
.set_scheme(Some(&Scheme::Https))
.map_err(|_| WSError::FulcioError("Failed to set HTTPS scheme".to_string()))?;
outgoing_request
.set_authority(Some(authority))
.map_err(|_| WSError::FulcioError("Failed to set authority".to_string()))?;
outgoing_request
.set_path_with_query(Some(&path))
.map_err(|_| WSError::FulcioError("Failed to set path".to_string()))?;
let body = outgoing_request
.body()
.map_err(|_| WSError::FulcioError("Failed to get request body".to_string()))?;
let request_stream = body
.write()
.map_err(|_| WSError::FulcioError("Failed to get request stream".to_string()))?;
request_stream
.blocking_write_and_flush(&request_json)
.map_err(|_| WSError::FulcioError("Failed to write request body".to_string()))?;
drop(request_stream);
OutgoingBody::finish(body, None)
.map_err(|_| WSError::FulcioError("Failed to finish request body".to_string()))?;
let future_response = outgoing_handler::handle(outgoing_request, None)
.map_err(|_| WSError::FulcioError("Failed to send HTTP request".to_string()))?;
let incoming_response = future_response
.get()
.ok_or_else(|| WSError::FulcioError("HTTP request not ready".to_string()))?
.map_err(|_| WSError::FulcioError("Failed to get HTTP response".to_string()))??;
let status = incoming_response.status();
if status != 200 && status != 201 {
return Err(WSError::FulcioError(format!(
"Fulcio returned status {}",
status
)));
}
let body = incoming_response
.consume()
.map_err(|_| WSError::FulcioError("Failed to get response body".to_string()))?;
let mut bytes = Vec::new();
let stream = body
.stream()
.map_err(|_| WSError::FulcioError("Failed to get body stream".to_string()))?;
loop {
let chunk = stream
.blocking_read(8192)
.map_err(|_| WSError::FulcioError("Failed to read from stream".to_string()))?;
if chunk.is_empty() {
break;
}
bytes.extend_from_slice(&chunk);
}
let fulcio_response: FulcioResponse = serde_json::from_slice(&bytes)
.map_err(|e| WSError::FulcioError(format!("Failed to parse Fulcio response: {}", e)))?;
Ok(fulcio_response)
}
}
impl Default for FulcioClient {
fn default() -> Self {
Self::new()
}
}
mod pem {
use crate::error::WSError;
pub struct Pem {
contents: Vec<u8>,
}
impl Pem {
pub fn contents(&self) -> &[u8] {
&self.contents
}
}
pub fn parse(pem_str: &str) -> Result<Pem, WSError> {
let begin_marker = "-----BEGIN CERTIFICATE-----";
let end_marker = "-----END CERTIFICATE-----";
let start = pem_str
.find(begin_marker)
.ok_or_else(|| WSError::FulcioError("No BEGIN CERTIFICATE marker found".to_string()))?
+ begin_marker.len();
let end = pem_str
.find(end_marker)
.ok_or_else(|| WSError::FulcioError("No END CERTIFICATE marker found".to_string()))?;
let base64_content = &pem_str[start..end];
let base64_clean = base64_content
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>();
let der_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
base64_clean.as_bytes(),
)
.map_err(|e| WSError::FulcioError(format!("Failed to decode base64: {}", e)))?;
Ok(Pem {
contents: der_bytes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fulcio_client_creation() {
let client = FulcioClient::new();
assert_eq!(client.base_url, "https://fulcio.sigstore.dev");
}
#[test]
fn test_fulcio_client_with_custom_url() {
let client = FulcioClient::with_url("https://custom.fulcio.dev".to_string());
assert_eq!(client.base_url, "https://custom.fulcio.dev");
}
#[test]
fn test_fulcio_request_serialization() {
let request = FulcioRequest {
credentials: Credentials {
oidc_identity_token: "test-token".to_string(),
},
public_key_request: PublicKeyRequest {
public_key: PublicKey {
algorithm: "ECDSA".to_string(), content: "test-key".to_string(),
},
proof_of_possession: "test-proof".to_string(),
},
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("oidcIdentityToken"));
assert!(json.contains("publicKeyRequest"));
assert!(json.contains("\"algorithm\":\"ECDSA\"")); }
#[test]
fn test_extract_public_key_from_pem() {
let pem_cert = r#"-----BEGIN CERTIFICATE-----
MIIBkzCCATmgAwIBAgIUXvZQVvZQVvZQVvZQVvZQVvZQVvYwCgYIKoZIzj0EAwIw
DzENMAsGA1UEAwwEdGVzdDAeFw0yNDAxMDEwMDAwMDBaFw0yNDAxMDEwMDEwMDBa
MA8xDTALBgNVBAMMBHRlc3QwKjAFBgMrZXADIQAqqqqqqqqqqqqqqqqqqqqqqqqqq
qqqqqqqqqqqqqqqqqo2UMFIwHQYDVR0OBBYEFAAAAAAAAAAAAAAAAAAAAAAAMB8G
A1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAADAMBgNVHRMBAf8EAjAAMAoGCCqG
SM49BAMCA0gAMEUCIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIg
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
-----END CERTIFICATE-----"#;
let result = FulcioClient::extract_public_key(pem_cert);
assert!(result.is_err());
}
#[test]
fn test_pem_parse() {
let pem_str = r#"-----BEGIN CERTIFICATE-----
SGVsbG8gV29ybGQh
-----END CERTIFICATE-----"#;
let result = pem::parse(pem_str);
assert!(result.is_ok());
let pem = result.unwrap();
assert_eq!(pem.contents(), b"Hello World!");
}
#[test]
fn test_encode_ecdsa_p256_spki() {
let mut raw_public_key = [0x42u8; 65];
raw_public_key[0] = 0x04;
let spki_der =
FulcioClient::encode_ecdsa_p256_spki(&raw_public_key).expect("Failed to encode SPKI");
assert!(!spki_der.is_empty());
assert_eq!(spki_der[0], 0x30, "Should start with SEQUENCE tag");
use spki::SubjectPublicKeyInfoRef;
let parsed = SubjectPublicKeyInfoRef::try_from(spki_der.as_slice())
.expect("Failed to parse generated SPKI");
assert_eq!(parsed.algorithm.oid.to_string(), "1.2.840.10045.2.1");
let key_bits = parsed.subject_public_key.raw_bytes();
assert_eq!(key_bits, &raw_public_key);
}
#[test]
fn test_encode_spki_to_pem() {
let test_der = vec![
0x30, 0x0a, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x02, 0x01, 0x03,
];
let pem = FulcioClient::encode_spki_to_pem(&test_der).expect("Failed to encode to PEM");
assert!(pem.starts_with("-----BEGIN PUBLIC KEY-----\n"));
assert!(pem.ends_with("-----END PUBLIC KEY-----"));
assert!(pem.len() > 60); }
#[test]
fn test_pem_parse_invalid() {
let pem_str = "Invalid PEM content";
let result = pem::parse(pem_str);
assert!(result.is_err());
}
#[test]
fn test_fulcio_certificate_structure() {
let cert = FulcioCertificate {
cert_chain: vec!["cert1".to_string(), "cert2".to_string()],
leaf_cert: "cert1".to_string(),
public_key: vec![1, 2, 3, 4],
};
assert_eq!(cert.cert_chain.len(), 2);
assert_eq!(cert.leaf_cert, "cert1");
assert_eq!(cert.public_key, vec![1, 2, 3, 4]);
}
#[test]
fn test_fulcio_response_deserialization() {
let json = r#"{
"signedCertificateEmbeddedSct": {
"chain": {
"certificates": [
"cert1",
"cert2"
]
}
}
}"#;
let response: FulcioResponse = serde_json::from_str(json).unwrap();
assert_eq!(
response
.signed_certificate_embedded_sct
.chain
.certificates
.len(),
2
);
}
#[test]
fn test_empty_certificate_chain_error() {
let cert_chain: Vec<String> = vec![];
assert!(cert_chain.is_empty());
}
}