Skip to main content

plecto_host/
dev_signer.rs

1//! `DevSigner`: a persistent, project-local ECDSA P-256 signer for the `plecto dev` inner loop
2//! (ADR 000065). In the lineage of `test_support::TestSigner` — same signing scheme, same
3//! `TrustPolicy` interop — but the key survives across process invocations instead of being
4//! thrown away each call. Unlike `TestSigner`, this is production code: `plecto dev` and
5//! `plecto new-filter` link it directly, not behind the `test-support` feature.
6//!
7//! The verification path this key exercises is byte-for-byte the same code a production
8//! deploy uses (ADR 000006 P5): a dev key changes *which* key the manifest's `[trust]` names,
9//! never how a signature is checked. See host/CONTEXT.md "Conformant (component)" and
10//! control/CONTEXT.md "Dev key" for the surrounding vocabulary.
11
12use std::fs;
13use std::io::{self, Write};
14use std::os::unix::fs::OpenOptionsExt;
15use std::path::{Path, PathBuf};
16
17use sigstore::crypto::signing_key::SigStoreSigner;
18use sigstore::crypto::signing_key::ecdsa::{ECDSAKeys, EllipticCurve};
19use zeroize::Zeroizing;
20
21use crate::TrustPolicy;
22
23/// Typed dev-key errors (bp-rust: a library's public surface stays `thiserror`; `anyhow` is
24/// for binary entry points). The CLI callers absorb this into `anyhow` at their edge.
25#[derive(Debug, thiserror::Error)]
26pub enum DevKeyError {
27    /// A sigstore crypto operation failed (key generation, PEM encode/decode, signing).
28    #[error("{op}: {source}")]
29    Crypto {
30        op: &'static str,
31        #[source]
32        source: sigstore::errors::SigstoreError,
33    },
34    /// A key file could not be created, read, or written.
35    #[error("{op} {}: {source}", path.display())]
36    Io {
37        op: &'static str,
38        path: PathBuf,
39        #[source]
40        source: io::Error,
41    },
42    /// The public key did not build a `TrustPolicy` — cannot happen for a key this module
43    /// generated, surfaced instead of unwrapped (`TrustPolicy::from_pem_keys` reports a plain
44    /// message, so no source chain to keep).
45    #[error("build trust policy from the dev public key: {0}")]
46    Trust(String),
47}
48
49/// Prefixed onto a persisted dev public-key file, before the PEM block. Plain text ahead of
50/// `-----BEGIN...` is not part of the PEM grammar (parsers skip straight to the marker), so
51/// this survives being read back as a normal SPKI PEM while staying grep-able — the hook
52/// `plecto validate` uses to warn when a dev key ends up in a production manifest's `[trust]`
53/// (ADR 000065 decision 5).
54pub const DEV_KEY_MARKER: &str = "# plecto-dev-key -- DO NOT reference from a production manifest";
55
56/// A persistent ECDSA P-256 / cosign-scheme signer. Holds one key pair; the same key signs
57/// both a filter component and its SBOM, matching a `TrustPolicy` that trusts exactly that key.
58pub struct DevSigner {
59    inner: PemSigner,
60}
61
62/// The neutral signing core: one ECDSA P-256 / cosign-scheme key pair, no persistence, no
63/// dev-key marker, no opinion about where the key came from. `plecto package` uses it with an
64/// operator's production key (field report §3.1); [`DevSigner`] wraps it with the dev-key
65/// file conventions. Signing here is a plain function of (key, bytes) — a future KMS /
66/// pre-computed-signature path replaces this struct at its call sites, nothing else.
67pub struct PemSigner {
68    signer: SigStoreSigner,
69    public_key_pem: String,
70}
71
72impl PemSigner {
73    /// Load a PKCS8 PEM private key. The elliptic curve is detected from the key's own OID
74    /// (sigstore-rs), so any ECDSA key of the cosign scheme works.
75    pub fn from_private_key_pem(pem: &[u8]) -> Result<Self, DevKeyError> {
76        let keys = ECDSAKeys::from_pem(pem).map_err(|e| DevKeyError::Crypto {
77            op: "load signing key",
78            source: e,
79        })?;
80        Self::from_keys(keys)
81    }
82
83    fn from_keys(keys: ECDSAKeys) -> Result<Self, DevKeyError> {
84        let public_key_pem =
85            keys.as_inner()
86                .public_key_to_pem()
87                .map_err(|e| DevKeyError::Crypto {
88                    op: "export public key",
89                    source: e,
90                })?;
91        let signer = keys.to_sigstore_signer().map_err(|e| DevKeyError::Crypto {
92            op: "build signer",
93            source: e,
94        })?;
95        Ok(Self {
96            signer,
97            public_key_pem,
98        })
99    }
100
101    /// Raw DER ECDSA signature over `msg` (the shape `SignedArtifact` expects).
102    pub fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, DevKeyError> {
103        self.signer.sign(msg).map_err(|e| DevKeyError::Crypto {
104            op: "sign",
105            source: e,
106        })
107    }
108
109    pub fn public_key_pem(&self) -> &str {
110        &self.public_key_pem
111    }
112}
113
114impl DevSigner {
115    /// Generate a fresh key pair. Returns the ready-to-use signer plus the PKCS8 PEM private
116    /// key, so the caller decides whether to persist it (`load_or_create`) or sign once and
117    /// drop it (an ephemeral self-signed conformance check, no file ever written).
118    pub fn generate() -> Result<(Self, Zeroizing<String>), DevKeyError> {
119        let keys = ECDSAKeys::new(EllipticCurve::P256).map_err(|e| DevKeyError::Crypto {
120            op: "generate dev key",
121            source: e,
122        })?;
123        let private_key_pem =
124            keys.as_inner()
125                .private_key_to_pem()
126                .map_err(|e| DevKeyError::Crypto {
127                    op: "export dev private key",
128                    source: e,
129                })?;
130        let signer = Self {
131            inner: PemSigner::from_keys(keys)?,
132        };
133        Ok((signer, private_key_pem))
134    }
135
136    /// Rebuild from a previously persisted PKCS8 PEM private key. The elliptic curve is
137    /// detected from the key's own OID (sigstore-rs), so this works for any dev key this
138    /// module has ever generated.
139    pub fn from_private_key_pem(pem: &[u8]) -> Result<Self, DevKeyError> {
140        Ok(Self {
141            inner: PemSigner::from_private_key_pem(pem)?,
142        })
143    }
144
145    /// Load the key at `private_key_path`, generating and persisting a fresh one on first use.
146    /// The private key is written `0600` (owner read/write only); the matching public key is
147    /// written alongside at `<private_key_path>.pub`, prefixed with [`DEV_KEY_MARKER`].
148    /// Does NOT touch `.gitignore` — that is the CLI caller's job (it knows the project root;
149    /// this function only knows the one key path it was given).
150    pub fn load_or_create(private_key_path: &Path) -> Result<Self, DevKeyError> {
151        match fs::read(private_key_path) {
152            Ok(pem) => {
153                // The read buffer holds the private key — zeroize it on drop, the same
154                // discipline `generate` applies to the PEM it returns.
155                let pem = Zeroizing::new(pem);
156                Self::from_private_key_pem(&pem)
157            }
158            Err(e) if e.kind() == io::ErrorKind::NotFound => {
159                let (signer, private_key_pem) = Self::generate()?;
160                write_dev_key_files(
161                    private_key_path,
162                    private_key_pem.as_bytes(),
163                    signer.public_key_pem(),
164                )?;
165                Ok(signer)
166            }
167            Err(e) => Err(DevKeyError::Io {
168                op: "read dev key",
169                path: private_key_path.to_path_buf(),
170                source: e,
171            }),
172        }
173    }
174
175    /// Raw DER ECDSA signature over `msg` (the shape `SignedArtifact` expects).
176    pub fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, DevKeyError> {
177        self.inner.sign(msg)
178    }
179
180    pub fn public_key_pem(&self) -> &str {
181        self.inner.public_key_pem()
182    }
183
184    /// A `TrustPolicy` that trusts exactly this signer's key.
185    pub fn trust_policy(&self) -> Result<TrustPolicy, DevKeyError> {
186        TrustPolicy::from_pem_keys([self.inner.public_key_pem().as_bytes()])
187            .map_err(|e| DevKeyError::Trust(e.to_string()))
188    }
189}
190
191/// The public-key sibling path `load_or_create` writes next to a private-key path
192/// (`<path>.pub`). Exposed so a caller (e.g. `plecto new-filter` writing a dev manifest's
193/// `[trust]`) can name it without re-deriving the convention.
194pub fn public_key_path_for(private_key_path: &Path) -> PathBuf {
195    let mut name = private_key_path.as_os_str().to_owned();
196    name.push(".pub");
197    PathBuf::from(name)
198}
199
200fn write_dev_key_files(
201    private_key_path: &Path,
202    private_key_pem: &[u8],
203    public_key_pem: &str,
204) -> Result<(), DevKeyError> {
205    let io_err = |op: &'static str, path: &Path| {
206        let path = path.to_path_buf();
207        move |source| DevKeyError::Io { op, path, source }
208    };
209    if let Some(parent) = private_key_path
210        .parent()
211        .filter(|p| !p.as_os_str().is_empty())
212    {
213        fs::create_dir_all(parent).map_err(io_err("create", parent))?;
214    }
215    // 0600 from the very first byte: creating with the default umask and chmodding afterwards
216    // would leave a window in which another local user can read the private key. `create_new`
217    // also refuses to clobber a key that appeared concurrently.
218    fs::OpenOptions::new()
219        .write(true)
220        .create_new(true)
221        .mode(0o600)
222        .open(private_key_path)
223        .map_err(io_err("create dev key", private_key_path))?
224        .write_all(private_key_pem)
225        .map_err(io_err("write dev key", private_key_path))?;
226
227    let public_key_path = public_key_path_for(private_key_path);
228    let marked = format!("{DEV_KEY_MARKER}\n{public_key_pem}");
229    fs::write(&public_key_path, marked)
230        .map_err(io_err("write dev public key", &public_key_path))?;
231    Ok(())
232}
233
234/// A minimal in-toto-style SBOM statement that binds `component`: its `subject` digest is
235/// `sha256(component)`, satisfying the load gate's SBOM↔component binding (review f000003 #1).
236/// The predicate is empty (content policy is deferred). Production helper — this crate's own
237/// `Host::load` verifies the binding it produces; a real supply chain gets its attestations
238/// from `cosign attest`. Also re-exported (unchanged) from `test_support` for the existing
239/// test suites.
240pub fn bound_sbom(component: &[u8]) -> Vec<u8> {
241    use sha2::{Digest, Sha256};
242    let digest = hex::encode(Sha256::digest(component));
243    format!(
244        r#"{{"_type":"https://in-toto.io/Statement/v1","subject":[{{"name":"filter","digest":{{"sha256":"{digest}"}}}}],"predicateType":"https://cyclonedx.org/bom","predicate":{{}}}}"#
245    )
246    .into_bytes()
247}
248
249#[cfg(test)]
250mod tests {
251    use std::os::unix::fs::PermissionsExt;
252
253    use super::*;
254
255    #[test]
256    fn generated_signer_is_trusted_by_its_own_policy() {
257        let (signer, _private_pem) = DevSigner::generate().unwrap();
258        let policy = signer.trust_policy().unwrap();
259        let sig = signer.sign(b"hello").unwrap();
260        assert!(policy.verifies(sig.as_slice(), b"hello"));
261    }
262
263    #[test]
264    fn load_or_create_persists_and_reloads_the_same_key() {
265        let dir = tempfile::tempdir().unwrap();
266        let key_path = dir.path().join(".plecto").join("dev-key");
267
268        let first = DevSigner::load_or_create(&key_path).unwrap();
269        assert!(key_path.exists());
270        assert!(public_key_path_for(&key_path).exists());
271
272        let second = DevSigner::load_or_create(&key_path).unwrap();
273        assert_eq!(first.public_key_pem(), second.public_key_pem());
274
275        // A signature from the reloaded signer must verify under a trust policy built from
276        // the FIRST run's public key -- proof the reload is the exact same key, not a
277        // same-shaped new one.
278        let policy = first.trust_policy().unwrap();
279        let sig = second.sign(b"round-trip").unwrap();
280        assert!(policy.verifies(sig.as_slice(), b"round-trip"));
281    }
282
283    #[test]
284    fn persisted_private_key_file_is_owner_only() {
285        let dir = tempfile::tempdir().unwrap();
286        let key_path = dir.path().join("dev-key");
287        DevSigner::load_or_create(&key_path).unwrap();
288
289        let mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
290        assert_eq!(mode, 0o600);
291    }
292
293    #[test]
294    fn persisted_public_key_file_carries_the_dev_marker() {
295        let dir = tempfile::tempdir().unwrap();
296        let key_path = dir.path().join("dev-key");
297        DevSigner::load_or_create(&key_path).unwrap();
298
299        let pub_contents = fs::read_to_string(public_key_path_for(&key_path)).unwrap();
300        assert!(pub_contents.starts_with(DEV_KEY_MARKER));
301        assert!(pub_contents.contains("BEGIN PUBLIC KEY"));
302    }
303
304    #[test]
305    fn bound_sbom_subject_digest_matches_component_sha256() {
306        use sha2::{Digest, Sha256};
307        let component = b"pretend-component-bytes";
308        let sbom = bound_sbom(component);
309        let want = hex::encode(Sha256::digest(component));
310        assert!(String::from_utf8(sbom).unwrap().contains(&want));
311    }
312}