use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use sigstore::crypto::signing_key::SigStoreSigner;
use sigstore::crypto::signing_key::ecdsa::{ECDSAKeys, EllipticCurve};
use zeroize::Zeroizing;
use crate::TrustPolicy;
#[derive(Debug, thiserror::Error)]
pub enum DevKeyError {
#[error("{op}: {source}")]
Crypto {
op: &'static str,
#[source]
source: sigstore::errors::SigstoreError,
},
#[error("{op} {}: {source}", path.display())]
Io {
op: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("build trust policy from the dev public key: {0}")]
Trust(String),
}
pub const DEV_KEY_MARKER: &str = "# plecto-dev-key -- DO NOT reference from a production manifest";
pub struct DevSigner {
signer: SigStoreSigner,
public_key_pem: String,
}
impl DevSigner {
pub fn generate() -> Result<(Self, Zeroizing<String>), DevKeyError> {
let keys = ECDSAKeys::new(EllipticCurve::P256).map_err(|e| DevKeyError::Crypto {
op: "generate dev key",
source: e,
})?;
let private_key_pem =
keys.as_inner()
.private_key_to_pem()
.map_err(|e| DevKeyError::Crypto {
op: "export dev private key",
source: e,
})?;
let signer = Self::from_keys(keys)?;
Ok((signer, private_key_pem))
}
pub fn from_private_key_pem(pem: &[u8]) -> Result<Self, DevKeyError> {
let keys = ECDSAKeys::from_pem(pem).map_err(|e| DevKeyError::Crypto {
op: "load dev key",
source: e,
})?;
Self::from_keys(keys)
}
fn from_keys(keys: ECDSAKeys) -> Result<Self, DevKeyError> {
let public_key_pem =
keys.as_inner()
.public_key_to_pem()
.map_err(|e| DevKeyError::Crypto {
op: "export dev public key",
source: e,
})?;
let signer = keys.to_sigstore_signer().map_err(|e| DevKeyError::Crypto {
op: "build dev signer",
source: e,
})?;
Ok(Self {
signer,
public_key_pem,
})
}
pub fn load_or_create(private_key_path: &Path) -> Result<Self, DevKeyError> {
match fs::read(private_key_path) {
Ok(pem) => {
let pem = Zeroizing::new(pem);
Self::from_private_key_pem(&pem)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let (signer, private_key_pem) = Self::generate()?;
write_dev_key_files(
private_key_path,
private_key_pem.as_bytes(),
signer.public_key_pem(),
)?;
Ok(signer)
}
Err(e) => Err(DevKeyError::Io {
op: "read dev key",
path: private_key_path.to_path_buf(),
source: e,
}),
}
}
pub fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, DevKeyError> {
self.signer.sign(msg).map_err(|e| DevKeyError::Crypto {
op: "sign",
source: e,
})
}
pub fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
pub fn trust_policy(&self) -> Result<TrustPolicy, DevKeyError> {
TrustPolicy::from_pem_keys([self.public_key_pem.as_bytes()])
.map_err(|e| DevKeyError::Trust(e.to_string()))
}
}
pub fn public_key_path_for(private_key_path: &Path) -> PathBuf {
let mut name = private_key_path.as_os_str().to_owned();
name.push(".pub");
PathBuf::from(name)
}
fn write_dev_key_files(
private_key_path: &Path,
private_key_pem: &[u8],
public_key_pem: &str,
) -> Result<(), DevKeyError> {
let io_err = |op: &'static str, path: &Path| {
let path = path.to_path_buf();
move |source| DevKeyError::Io { op, path, source }
};
if let Some(parent) = private_key_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(io_err("create", parent))?;
}
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(private_key_path)
.map_err(io_err("create dev key", private_key_path))?
.write_all(private_key_pem)
.map_err(io_err("write dev key", private_key_path))?;
let public_key_path = public_key_path_for(private_key_path);
let marked = format!("{DEV_KEY_MARKER}\n{public_key_pem}");
fs::write(&public_key_path, marked)
.map_err(io_err("write dev public key", &public_key_path))?;
Ok(())
}
pub fn bound_sbom(component: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
let digest = hex::encode(Sha256::digest(component));
format!(
r#"{{"_type":"https://in-toto.io/Statement/v1","subject":[{{"name":"filter","digest":{{"sha256":"{digest}"}}}}],"predicateType":"https://cyclonedx.org/bom","predicate":{{}}}}"#
)
.into_bytes()
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
#[test]
fn generated_signer_is_trusted_by_its_own_policy() {
let (signer, _private_pem) = DevSigner::generate().unwrap();
let policy = signer.trust_policy().unwrap();
let sig = signer.sign(b"hello").unwrap();
assert!(policy.verifies(sig.as_slice(), b"hello"));
}
#[test]
fn load_or_create_persists_and_reloads_the_same_key() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join(".plecto").join("dev-key");
let first = DevSigner::load_or_create(&key_path).unwrap();
assert!(key_path.exists());
assert!(public_key_path_for(&key_path).exists());
let second = DevSigner::load_or_create(&key_path).unwrap();
assert_eq!(first.public_key_pem(), second.public_key_pem());
let policy = first.trust_policy().unwrap();
let sig = second.sign(b"round-trip").unwrap();
assert!(policy.verifies(sig.as_slice(), b"round-trip"));
}
#[test]
fn persisted_private_key_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("dev-key");
DevSigner::load_or_create(&key_path).unwrap();
let mode = fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn persisted_public_key_file_carries_the_dev_marker() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("dev-key");
DevSigner::load_or_create(&key_path).unwrap();
let pub_contents = fs::read_to_string(public_key_path_for(&key_path)).unwrap();
assert!(pub_contents.starts_with(DEV_KEY_MARKER));
assert!(pub_contents.contains("BEGIN PUBLIC KEY"));
}
#[test]
fn bound_sbom_subject_digest_matches_component_sha256() {
use sha2::{Digest, Sha256};
let component = b"pretend-component-bytes";
let sbom = bound_sbom(component);
let want = hex::encode(Sha256::digest(component));
assert!(String::from_utf8(sbom).unwrap().contains(&want));
}
}