plecto_host/
dev_signer.rs1use 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#[derive(Debug, thiserror::Error)]
26pub enum DevKeyError {
27 #[error("{op}: {source}")]
29 Crypto {
30 op: &'static str,
31 #[source]
32 source: sigstore::errors::SigstoreError,
33 },
34 #[error("{op} {}: {source}", path.display())]
36 Io {
37 op: &'static str,
38 path: PathBuf,
39 #[source]
40 source: io::Error,
41 },
42 #[error("build trust policy from the dev public key: {0}")]
46 Trust(String),
47}
48
49pub const DEV_KEY_MARKER: &str = "# plecto-dev-key -- DO NOT reference from a production manifest";
55
56pub struct DevSigner {
59 inner: PemSigner,
60}
61
62pub struct PemSigner {
68 signer: SigStoreSigner,
69 public_key_pem: String,
70}
71
72impl PemSigner {
73 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 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 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 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 pub fn load_or_create(private_key_path: &Path) -> Result<Self, DevKeyError> {
151 match fs::read(private_key_path) {
152 Ok(pem) => {
153 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 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 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
191pub 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 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
234pub 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 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}