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 signer: SigStoreSigner,
60 public_key_pem: String,
61}
62
63impl DevSigner {
64 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 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 pub fn load_or_create(private_key_path: &Path) -> Result<Self, DevKeyError> {
118 match fs::read(private_key_path) {
119 Ok(pem) => {
120 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 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 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
161pub 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 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
204pub 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 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}