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    signer: SigStoreSigner,
60    public_key_pem: String,
61}
62
63impl DevSigner {
64    /// Generate a fresh key pair. Returns the ready-to-use signer plus the PKCS8 PEM private
65    /// key, so the caller decides whether to persist it (`load_or_create`) or sign once and
66    /// drop it (an ephemeral self-signed conformance check, no file ever written).
67    pub fn generate() -> Result<(Self, Zeroizing<String>), DevKeyError> {
68        let keys = ECDSAKeys::new(EllipticCurve::P256).map_err(|e| DevKeyError::Crypto {
69            op: "generate dev key",
70            source: e,
71        })?;
72        let private_key_pem =
73            keys.as_inner()
74                .private_key_to_pem()
75                .map_err(|e| DevKeyError::Crypto {
76                    op: "export dev private key",
77                    source: e,
78                })?;
79        let signer = Self::from_keys(keys)?;
80        Ok((signer, private_key_pem))
81    }
82
83    /// Rebuild from a previously persisted PKCS8 PEM private key. The elliptic curve is
84    /// detected from the key's own OID (sigstore-rs), so this works for any dev key this
85    /// module has ever generated.
86    pub fn from_private_key_pem(pem: &[u8]) -> Result<Self, DevKeyError> {
87        let keys = ECDSAKeys::from_pem(pem).map_err(|e| DevKeyError::Crypto {
88            op: "load dev key",
89            source: e,
90        })?;
91        Self::from_keys(keys)
92    }
93
94    fn from_keys(keys: ECDSAKeys) -> Result<Self, DevKeyError> {
95        let public_key_pem =
96            keys.as_inner()
97                .public_key_to_pem()
98                .map_err(|e| DevKeyError::Crypto {
99                    op: "export dev public key",
100                    source: e,
101                })?;
102        let signer = keys.to_sigstore_signer().map_err(|e| DevKeyError::Crypto {
103            op: "build dev signer",
104            source: e,
105        })?;
106        Ok(Self {
107            signer,
108            public_key_pem,
109        })
110    }
111
112    /// Load the key at `private_key_path`, generating and persisting a fresh one on first use.
113    /// The private key is written `0600` (owner read/write only); the matching public key is
114    /// written alongside at `<private_key_path>.pub`, prefixed with [`DEV_KEY_MARKER`].
115    /// Does NOT touch `.gitignore` — that is the CLI caller's job (it knows the project root;
116    /// this function only knows the one key path it was given).
117    pub fn load_or_create(private_key_path: &Path) -> Result<Self, DevKeyError> {
118        match fs::read(private_key_path) {
119            Ok(pem) => {
120                // The read buffer holds the private key — zeroize it on drop, the same
121                // discipline `generate` applies to the PEM it returns.
122                let pem = Zeroizing::new(pem);
123                Self::from_private_key_pem(&pem)
124            }
125            Err(e) if e.kind() == io::ErrorKind::NotFound => {
126                let (signer, private_key_pem) = Self::generate()?;
127                write_dev_key_files(
128                    private_key_path,
129                    private_key_pem.as_bytes(),
130                    signer.public_key_pem(),
131                )?;
132                Ok(signer)
133            }
134            Err(e) => Err(DevKeyError::Io {
135                op: "read dev key",
136                path: private_key_path.to_path_buf(),
137                source: e,
138            }),
139        }
140    }
141
142    /// Raw DER ECDSA signature over `msg` (the shape `SignedArtifact` expects).
143    pub fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, DevKeyError> {
144        self.signer.sign(msg).map_err(|e| DevKeyError::Crypto {
145            op: "sign",
146            source: e,
147        })
148    }
149
150    pub fn public_key_pem(&self) -> &str {
151        &self.public_key_pem
152    }
153
154    /// A `TrustPolicy` that trusts exactly this signer's key.
155    pub fn trust_policy(&self) -> Result<TrustPolicy, DevKeyError> {
156        TrustPolicy::from_pem_keys([self.public_key_pem.as_bytes()])
157            .map_err(|e| DevKeyError::Trust(e.to_string()))
158    }
159}
160
161/// The public-key sibling path `load_or_create` writes next to a private-key path
162/// (`<path>.pub`). Exposed so a caller (e.g. `plecto new-filter` writing a dev manifest's
163/// `[trust]`) can name it without re-deriving the convention.
164pub fn public_key_path_for(private_key_path: &Path) -> PathBuf {
165    let mut name = private_key_path.as_os_str().to_owned();
166    name.push(".pub");
167    PathBuf::from(name)
168}
169
170fn write_dev_key_files(
171    private_key_path: &Path,
172    private_key_pem: &[u8],
173    public_key_pem: &str,
174) -> Result<(), DevKeyError> {
175    let io_err = |op: &'static str, path: &Path| {
176        let path = path.to_path_buf();
177        move |source| DevKeyError::Io { op, path, source }
178    };
179    if let Some(parent) = private_key_path
180        .parent()
181        .filter(|p| !p.as_os_str().is_empty())
182    {
183        fs::create_dir_all(parent).map_err(io_err("create", parent))?;
184    }
185    // 0600 from the very first byte: creating with the default umask and chmodding afterwards
186    // would leave a window in which another local user can read the private key. `create_new`
187    // also refuses to clobber a key that appeared concurrently.
188    fs::OpenOptions::new()
189        .write(true)
190        .create_new(true)
191        .mode(0o600)
192        .open(private_key_path)
193        .map_err(io_err("create dev key", private_key_path))?
194        .write_all(private_key_pem)
195        .map_err(io_err("write dev key", private_key_path))?;
196
197    let public_key_path = public_key_path_for(private_key_path);
198    let marked = format!("{DEV_KEY_MARKER}\n{public_key_pem}");
199    fs::write(&public_key_path, marked)
200        .map_err(io_err("write dev public key", &public_key_path))?;
201    Ok(())
202}
203
204/// A minimal in-toto-style SBOM statement that binds `component`: its `subject` digest is
205/// `sha256(component)`, satisfying the load gate's SBOM↔component binding (review f000003 #1).
206/// The predicate is empty (content policy is deferred). Production helper — this crate's own
207/// `Host::load` verifies the binding it produces; a real supply chain gets its attestations
208/// from `cosign attest`. Also re-exported (unchanged) from `test_support` for the existing
209/// test suites.
210pub fn bound_sbom(component: &[u8]) -> Vec<u8> {
211    use sha2::{Digest, Sha256};
212    let digest = hex::encode(Sha256::digest(component));
213    format!(
214        r#"{{"_type":"https://in-toto.io/Statement/v1","subject":[{{"name":"filter","digest":{{"sha256":"{digest}"}}}}],"predicateType":"https://cyclonedx.org/bom","predicate":{{}}}}"#
215    )
216    .into_bytes()
217}
218
219#[cfg(test)]
220mod tests {
221    use std::os::unix::fs::PermissionsExt;
222
223    use super::*;
224
225    #[test]
226    fn generated_signer_is_trusted_by_its_own_policy() {
227        let (signer, _private_pem) = DevSigner::generate().unwrap();
228        let policy = signer.trust_policy().unwrap();
229        let sig = signer.sign(b"hello").unwrap();
230        assert!(policy.verifies(sig.as_slice(), b"hello"));
231    }
232
233    #[test]
234    fn load_or_create_persists_and_reloads_the_same_key() {
235        let dir = tempfile::tempdir().unwrap();
236        let key_path = dir.path().join(".plecto").join("dev-key");
237
238        let first = DevSigner::load_or_create(&key_path).unwrap();
239        assert!(key_path.exists());
240        assert!(public_key_path_for(&key_path).exists());
241
242        let second = DevSigner::load_or_create(&key_path).unwrap();
243        assert_eq!(first.public_key_pem(), second.public_key_pem());
244
245        // A signature from the reloaded signer must verify under a trust policy built from
246        // the FIRST run's public key -- proof the reload is the exact same key, not a
247        // same-shaped new one.
248        let policy = first.trust_policy().unwrap();
249        let sig = second.sign(b"round-trip").unwrap();
250        assert!(policy.verifies(sig.as_slice(), b"round-trip"));
251    }
252
253    #[test]
254    fn persisted_private_key_file_is_owner_only() {
255        let dir = tempfile::tempdir().unwrap();
256        let key_path = dir.path().join("dev-key");
257        DevSigner::load_or_create(&key_path).unwrap();
258
259        let mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
260        assert_eq!(mode, 0o600);
261    }
262
263    #[test]
264    fn persisted_public_key_file_carries_the_dev_marker() {
265        let dir = tempfile::tempdir().unwrap();
266        let key_path = dir.path().join("dev-key");
267        DevSigner::load_or_create(&key_path).unwrap();
268
269        let pub_contents = fs::read_to_string(public_key_path_for(&key_path)).unwrap();
270        assert!(pub_contents.starts_with(DEV_KEY_MARKER));
271        assert!(pub_contents.contains("BEGIN PUBLIC KEY"));
272    }
273
274    #[test]
275    fn bound_sbom_subject_digest_matches_component_sha256() {
276        use sha2::{Digest, Sha256};
277        let component = b"pretend-component-bytes";
278        let sbom = bound_sbom(component);
279        let want = hex::encode(Sha256::digest(component));
280        assert!(String::from_utf8(sbom).unwrap().contains(&want));
281    }
282}