Skip to main content

a3s_box_runtime/oci/signing/
sign.rs

1//! Image signing (cosign-compatible): create and push a signature artifact.
2
3use a3s_box_core::error::{BoxError, Result};
4use oci_distribution::secrets::RegistryAuth;
5use oci_distribution::{Client, Reference};
6
7use super::crypto::{base64_decode, base64_encode};
8use super::{
9    cosign_signature_tag, CosignCritical, CosignIdentity, CosignImage, CosignPayload,
10    CosignSignatureEnvelope,
11};
12
13/// Result of a successful image signing operation.
14#[derive(Debug, Clone)]
15pub struct SignResult {
16    /// The signature tag pushed to the registry (e.g., "sha256-abc123.sig").
17    pub signature_tag: String,
18}
19
20/// Sign an image after push using a PEM-encoded ECDSA P-256 private key.
21///
22/// Creates a cosign-compatible signature artifact and pushes it to the registry
23/// as a separate image with the `.sig` tag convention.
24///
25/// # Arguments
26/// * `private_key_path` - Path to PEM-encoded ECDSA P-256 private key
27/// * `registry` - Registry hostname (e.g., "ghcr.io")
28/// * `repository` - Repository path (e.g., "myorg/myimage")
29/// * `manifest_digest` - Digest of the pushed manifest (e.g., "sha256:abc123...")
30/// * `docker_reference` - Full image reference (e.g., "ghcr.io/myorg/myimage:latest")
31pub async fn sign_image(
32    private_key_path: &str,
33    registry: &str,
34    repository: &str,
35    manifest_digest: &str,
36    docker_reference: &str,
37) -> Result<SignResult> {
38    use p256::ecdsa::signature::Signer;
39
40    // 1. Read and parse the private key
41    let pem_bytes = std::fs::read(private_key_path).map_err(|e| {
42        BoxError::OciImageError(format!(
43            "Failed to read signing key '{}': {}",
44            private_key_path, e
45        ))
46    })?;
47    let signing_key = parse_pem_private_key(&pem_bytes)
48        .map_err(|e| BoxError::OciImageError(format!("Failed to parse signing key: {}", e)))?;
49
50    // 2. Build the SimpleSigning payload
51    let payload = CosignPayload {
52        critical: CosignCritical {
53            identity: CosignIdentity {
54                docker_reference: docker_reference.to_string(),
55            },
56            image: CosignImage {
57                docker_manifest_digest: manifest_digest.to_string(),
58            },
59            sig_type: "cosign container image signature".to_string(),
60        },
61        optional: serde_json::json!({}),
62    };
63    let payload_bytes = serde_json::to_vec(&payload).map_err(|e| {
64        BoxError::SerializationError(format!("Failed to serialize cosign payload: {}", e))
65    })?;
66
67    // 3. Sign the payload with ECDSA P-256
68    let signature: p256::ecdsa::DerSignature = signing_key.sign(&payload_bytes);
69
70    // 4. Build the cosign signature envelope
71    let envelope = CosignSignatureEnvelope {
72        payload: base64_encode(&payload_bytes),
73        signature: base64_encode(signature.as_bytes()),
74    };
75    let envelope_bytes = serde_json::to_vec(&envelope).map_err(|e| {
76        BoxError::SerializationError(format!("Failed to serialize signature envelope: {}", e))
77    })?;
78
79    // 5. Push the signature as an OCI image with the .sig tag
80    let sig_tag = cosign_signature_tag(manifest_digest);
81    let sig_reference_str = format!("{}/{}:{}", registry, repository, sig_tag);
82
83    let sig_reference: Reference =
84        sig_reference_str
85            .parse()
86            .map_err(|e| BoxError::RegistryError {
87                registry: registry.to_string(),
88                message: format!("Invalid signature reference: {}", e),
89            })?;
90
91    let config = oci_distribution::client::ClientConfig {
92        protocol: oci_distribution::client::ClientProtocol::Https,
93        ..Default::default()
94    };
95    let client = Client::new(config);
96
97    // The signature layer uses the cosign media type
98    let sig_layer = oci_distribution::client::ImageLayer::new(
99        envelope_bytes,
100        "application/vnd.dev.cosign.simplesigning.v1+json".to_string(),
101        None,
102    );
103
104    // Empty config for the signature image
105    let sig_config = oci_distribution::client::Config::new(
106        b"{}".to_vec(),
107        "application/vnd.oci.image.config.v1+json".to_string(),
108        None,
109    );
110
111    client
112        .push(
113            &sig_reference,
114            &[sig_layer],
115            sig_config,
116            &RegistryAuth::Anonymous,
117            None,
118        )
119        .await
120        .map_err(|e| BoxError::RegistryError {
121            registry: registry.to_string(),
122            message: format!("Failed to push signature artifact: {}", e),
123        })?;
124
125    tracing::info!(
126        digest = %manifest_digest,
127        signature_tag = %sig_tag,
128        "Image signed and signature pushed"
129    );
130
131    Ok(SignResult {
132        signature_tag: sig_tag,
133    })
134}
135
136/// Parse a PEM-encoded ECDSA P-256 private key.
137///
138/// Supports "EC PRIVATE KEY" (SEC1) and "PRIVATE KEY" (PKCS#8) PEM formats.
139pub(super) fn parse_pem_private_key(
140    pem_bytes: &[u8],
141) -> std::result::Result<p256::ecdsa::SigningKey, String> {
142    let pem_str = std::str::from_utf8(pem_bytes)
143        .map_err(|e| format!("PEM file is not valid UTF-8: {}", e))?;
144
145    let der_bytes = if pem_str.contains("BEGIN EC PRIVATE KEY") {
146        // SEC1 format
147        extract_pem_content(
148            pem_str,
149            "-----BEGIN EC PRIVATE KEY-----",
150            "-----END EC PRIVATE KEY-----",
151        )?
152    } else if pem_str.contains("BEGIN PRIVATE KEY") {
153        // PKCS#8 format
154        extract_pem_content(
155            pem_str,
156            "-----BEGIN PRIVATE KEY-----",
157            "-----END PRIVATE KEY-----",
158        )?
159    } else {
160        return Err("Unsupported PEM format: expected EC PRIVATE KEY or PRIVATE KEY".to_string());
161    };
162
163    // Try SEC1 first, then PKCS#8
164    if let Ok(key) = p256::SecretKey::from_sec1_der(&der_bytes) {
165        return Ok(p256::ecdsa::SigningKey::from(key));
166    }
167
168    // Try PKCS#8
169    use p256::pkcs8::DecodePrivateKey;
170    p256::SecretKey::from_pkcs8_der(&der_bytes)
171        .map(p256::ecdsa::SigningKey::from)
172        .map_err(|e| format!("Failed to parse P-256 private key: {}", e))
173}
174
175/// Extract base64 content between PEM markers.
176pub(super) fn extract_pem_content(
177    pem_str: &str,
178    begin_marker: &str,
179    end_marker: &str,
180) -> std::result::Result<Vec<u8>, String> {
181    let start = pem_str
182        .find(begin_marker)
183        .ok_or("Missing PEM begin marker")?
184        + begin_marker.len();
185    let end = pem_str.find(end_marker).ok_or("Missing PEM end marker")?;
186
187    let b64: String = pem_str[start..end]
188        .chars()
189        .filter(|c| !c.is_whitespace())
190        .collect();
191
192    base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e))
193}