Skip to main content

fakecloud_ecr/
signing.rs

1//! Real OCI image signature verification via cosign (keyed mode).
2//!
3//! Cosign stores signatures as a companion manifest tagged
4//! `sha256-<image-digest>.sig`. That manifest has one layer with
5//! `mediaType: application/vnd.dev.cosign.simplesigning.v1+json`
6//! whose blob is a JSON "simple-signing payload" that names the
7//! signed image. The cosign signature (ECDSA) is attached as an
8//! annotation on the layer descriptor with key
9//! `dev.cosignproject.cosign/signature`, and the signed bytes are
10//! the layer blob itself.
11//!
12//! This module supports the common case: ECDSA-P256 keys encoded as
13//! PEM (PKCS8 SubjectPublicKeyInfo). No sigstore transparency log,
14//! no keyless / Fulcio / Rekor — that's scoped out until fakecloud
15//! grows a Rekor shim.
16
17use base64::engine::general_purpose::STANDARD as B64;
18use base64::Engine;
19use p256::ecdsa::signature::Verifier;
20use p256::ecdsa::{Signature, VerifyingKey};
21use p256::pkcs8::DecodePublicKey;
22use serde::{Deserialize, Serialize};
23
24/// Companion tag convention: `sha256-<hex>.sig` sits in the same repo
25/// as the signed image and points at a cosign simple-signing manifest.
26pub fn companion_sig_tag(image_digest: &str) -> Option<String> {
27    image_digest
28        .strip_prefix("sha256:")
29        .map(|hex| format!("sha256-{hex}.sig"))
30}
31
32/// Structured form of a trusted public key. Stored inside
33/// `SigningConfiguration.trusted_keys` so `PutSigningConfiguration`
34/// validates PEMs up front and `DescribeImageSigningStatus` doesn't
35/// re-parse on every call.
36#[derive(Clone, Debug, Default, Serialize, Deserialize)]
37pub struct TrustedKey {
38    pub key_id: String,
39    pub pem: String,
40    /// Cosmetic label for the key-usage algorithm. Only ECDSA-P256 is
41    /// supported for verification today; other values are stored
42    /// round-trippably but won't match a signature.
43    pub algorithm: String,
44}
45
46#[derive(Debug)]
47pub enum VerifyError {
48    InvalidPemKey,
49    SignatureDecode,
50    SignatureVerify,
51}
52
53/// Verify `signature_b64` (base64 DER ECDSA signature) over `payload`
54/// using `key_pem` (PEM-wrapped PKCS8 SubjectPublicKeyInfo for
55/// ECDSA-P256). Matches cosign's default verification flow.
56pub fn verify_cosign_signature(
57    key_pem: &str,
58    payload: &[u8],
59    signature_b64: &str,
60) -> Result<(), VerifyError> {
61    let verifying_key =
62        VerifyingKey::from_public_key_pem(key_pem).map_err(|_| VerifyError::InvalidPemKey)?;
63    let sig_bytes = B64
64        .decode(signature_b64.trim().as_bytes())
65        .map_err(|_| VerifyError::SignatureDecode)?;
66    let sig = Signature::from_der(&sig_bytes).map_err(|_| VerifyError::SignatureDecode)?;
67    verifying_key
68        .verify(payload, &sig)
69        .map_err(|_| VerifyError::SignatureVerify)
70}
71
72/// Walk the layers of a cosign signature manifest and pull the
73/// `dev.cosignproject.cosign/signature` annotation plus the layer
74/// digest. Returns the layer whose blob is the simple-signing
75/// payload.
76pub fn extract_signature_annotation(manifest_json: &serde_json::Value) -> Option<(String, String)> {
77    let layer = manifest_json.get("layers")?.as_array()?.first()?;
78    let digest = layer.get("digest")?.as_str()?.to_string();
79    let sig = layer
80        .get("annotations")?
81        .as_object()?
82        .get("dev.cosignproject.cosign/signature")?
83        .as_str()?
84        .to_string();
85    Some((digest, sig))
86}
87
88/// Parse cosign's simple-signing payload and pull the referenced
89/// image manifest digest. Used to verify the signed payload names
90/// the image we're checking.
91pub fn referenced_image_digest(payload_bytes: &[u8]) -> Option<String> {
92    let v: serde_json::Value = serde_json::from_slice(payload_bytes).ok()?;
93    v.get("critical")?
94        .get("image")?
95        .get("docker-manifest-digest")?
96        .as_str()
97        .map(String::from)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use p256::ecdsa::signature::Signer;
104    use p256::ecdsa::SigningKey;
105    use p256::pkcs8::EncodePublicKey;
106
107    fn keypair_pem() -> (SigningKey, String) {
108        // Deterministic P-256 key — fakecloud doesn't need real
109        // entropy for unit tests.
110        let bytes = [7u8; 32];
111        let sk = SigningKey::from_bytes((&bytes).into()).unwrap();
112        let pem = sk
113            .verifying_key()
114            .to_public_key_pem(Default::default())
115            .unwrap();
116        (sk, pem)
117    }
118
119    #[test]
120    fn verify_roundtrip() {
121        let (sk, pem) = keypair_pem();
122        let payload = br#"{"critical":{"image":{"docker-manifest-digest":"sha256:abc"}}}"#;
123        let sig: Signature = sk.sign(payload);
124        let sig_b64 = B64.encode(sig.to_der());
125        verify_cosign_signature(&pem, payload, &sig_b64).unwrap();
126    }
127
128    #[test]
129    fn wrong_payload_fails() {
130        let (sk, pem) = keypair_pem();
131        let payload = b"original";
132        let sig: Signature = sk.sign(payload);
133        let sig_b64 = B64.encode(sig.to_der());
134        assert!(matches!(
135            verify_cosign_signature(&pem, b"tampered", &sig_b64),
136            Err(VerifyError::SignatureVerify)
137        ));
138    }
139
140    #[test]
141    fn malformed_pem_rejected() {
142        assert!(matches!(
143            verify_cosign_signature("not a pem", b"payload", "ignored"),
144            Err(VerifyError::InvalidPemKey)
145        ));
146    }
147
148    #[test]
149    fn companion_tag_shape() {
150        assert_eq!(
151            companion_sig_tag("sha256:abc123"),
152            Some("sha256-abc123.sig".to_string())
153        );
154        assert_eq!(companion_sig_tag("bare-tag"), None);
155    }
156
157    #[test]
158    fn extracts_layer_annotation() {
159        let m = serde_json::json!({
160            "layers": [{
161                "digest": "sha256:deadbeef",
162                "annotations": {
163                    "dev.cosignproject.cosign/signature": "sig-b64"
164                }
165            }]
166        });
167        assert_eq!(
168            extract_signature_annotation(&m),
169            Some(("sha256:deadbeef".into(), "sig-b64".into()))
170        );
171    }
172
173    #[test]
174    fn parses_payload_referenced_digest() {
175        let p = br#"{"critical":{"image":{"docker-manifest-digest":"sha256:target"}}}"#;
176        assert_eq!(referenced_image_digest(p).as_deref(), Some("sha256:target"));
177    }
178}