use anyhow::{Context, Result};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use std::path::Path;
const PUBLIC_KEY_LEN: usize = 32;
const SIGNATURE_LEN: usize = 64;
pub(crate) const SIGNATURE_ALG: &str = "ed25519";
pub(crate) fn load_signing_key(path: &Path) -> Result<SigningKey> {
let bytes = std::fs::read(path)
.with_context(|| format!("failed to read signing key: {}", path.display()))?;
let seed: [u8; 32] = match bytes.len() {
32 => bytes.try_into().expect("length checked above"),
64 => decode_hex_seed(&bytes)
.with_context(|| format!("invalid hex signing key in {}", path.display()))?,
_ => anyhow::bail!(
"signing key '{}' must be 32 raw bytes or 64 hex characters, got {} bytes",
path.display(),
bytes.len()
),
};
Ok(SigningKey::from_bytes(&seed))
}
fn decode_hex_seed(bytes: &[u8]) -> Result<[u8; 32]> {
let text = std::str::from_utf8(bytes).context("signing key is neither raw 32 bytes nor hex")?;
let hex_str = text.trim();
let mut seed = [0u8; 32];
for (i, chunk) in hex_str.as_bytes().chunks(2).enumerate() {
if chunk.len() != 2 {
anyhow::bail!("odd-length hex key");
}
seed[i] = u8::from_str_radix(std::str::from_utf8(chunk)?, 16)
.map_err(|e| anyhow::anyhow!("invalid hex in signing key: {e}"))?;
}
Ok(seed)
}
pub(crate) fn sign_manifest(
manifest: &mut serde_json::Value,
signing_key: &SigningKey,
) -> Result<()> {
let payload = signing_payload(manifest)?;
let signature = signing_key.sign(&payload);
let public_key = signing_key.verifying_key().to_bytes();
if let Some(obj) = manifest.as_object_mut() {
obj.insert(
"signatures".to_string(),
serde_json::json!([{
"alg": SIGNATURE_ALG,
"public_key": hex::encode(public_key),
"signature": hex::encode(signature.to_bytes()),
}]),
);
}
Ok(())
}
pub(crate) fn verify_manifest_signatures(manifest: &serde_json::Value) -> Result<usize> {
let signatures = match manifest.get("signatures") {
None | Some(serde_json::Value::Null) => return Ok(0),
Some(serde_json::Value::Array(items)) => items,
Some(_) => anyhow::bail!("manifest 'signatures' must be an array"),
};
if signatures.is_empty() {
return Ok(0);
}
let payload = signing_payload(manifest)?;
for (i, entry) in signatures.iter().enumerate() {
let alg = entry["alg"].as_str().unwrap_or_default();
if alg != SIGNATURE_ALG {
anyhow::bail!(
"signature #{i} uses unsupported algorithm '{alg}' (expected '{SIGNATURE_ALG}')"
);
}
let public_key_hex = entry["public_key"]
.as_str()
.with_context(|| format!("signature #{i} missing 'public_key'"))?;
let signature_hex = entry["signature"]
.as_str()
.with_context(|| format!("signature #{i} missing 'signature'"))?;
let public_key = decode_fixed_hex::<PUBLIC_KEY_LEN>(public_key_hex, "public key")
.with_context(|| format!("signature #{i} public key"))?;
let sig_bytes = decode_fixed_hex::<SIGNATURE_LEN>(signature_hex, "signature")
.with_context(|| format!("signature #{i} signature"))?;
let verifying_key = VerifyingKey::from_bytes(&public_key)
.map_err(|e| anyhow::anyhow!("signature #{i} has an invalid public key: {e}"))?;
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify(&payload, &signature)
.map_err(|_| anyhow::anyhow!("signature #{i} does not match the manifest — the bundle content or the signature was altered"))?;
}
Ok(signatures.len())
}
fn signing_payload(manifest: &serde_json::Value) -> Result<Vec<u8>> {
let mut payload = manifest.clone();
if let Some(obj) = payload.as_object_mut() {
obj.remove("signatures");
}
serde_json::to_vec(&payload).context("failed to serialize manifest for signing")
}
fn decode_fixed_hex<const N: usize>(text: &str, what: &str) -> Result<[u8; N]> {
let trimmed = text.trim();
if trimmed.len() != N * 2 {
anyhow::bail!(
"invalid {what}: expected {} hex characters, got {}",
N * 2,
trimmed.len()
);
}
let mut out = [0u8; N];
for (i, chunk) in trimmed.as_bytes().chunks(2).enumerate() {
out[i] = u8::from_str_radix(
std::str::from_utf8(chunk).map_err(|_| anyhow::anyhow!("invalid {what} hex"))?,
16,
)
.map_err(|e| anyhow::anyhow!("invalid {what} hex: {e}"))?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::bundle::BundleFormat;
use std::path::PathBuf;
fn test_seed() -> [u8; 32] {
let mut seed = [0u8; 32];
for (i, b) in seed.iter_mut().enumerate() {
*b = (i as u8) * 7;
}
seed
}
fn test_signing_key() -> SigningKey {
SigningKey::from_bytes(&test_seed())
}
fn sample_manifest() -> serde_json::Value {
serde_json::json!({
"format": "oxoflow-bundle-v1",
"workflow": "w.oxoflow",
"entrypoint": "w.oxoflow",
"files": [{"path": "w.oxoflow", "sha256": "abc"}],
"signatures": []
})
}
fn write_hex_key(dir: &std::path::Path) -> PathBuf {
let path = dir.join("signing.hex");
std::fs::write(&path, hex::encode(test_seed())).unwrap();
path
}
#[test]
fn signing_adds_a_verifiable_signature_entry() {
let mut manifest = sample_manifest();
sign_manifest(&mut manifest, &test_signing_key()).unwrap();
let signatures = manifest["signatures"].as_array().unwrap();
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0]["alg"], "ed25519");
assert_eq!(
verify_manifest_signatures(&manifest).unwrap(),
1,
"the freshly attached signature must verify"
);
}
#[test]
fn tampered_manifest_content_fails_verification() {
let mut manifest = sample_manifest();
sign_manifest(&mut manifest, &test_signing_key()).unwrap();
manifest["files"][0]["sha256"] = serde_json::json!("abd");
assert!(
verify_manifest_signatures(&manifest).is_err(),
"a signed manifest must fail verification after any content change"
);
}
#[test]
fn wrong_key_signature_fails_verification() {
let mut manifest = sample_manifest();
let mut other_seed = test_seed();
other_seed[0] ^= 0xff;
sign_manifest(&mut manifest, &SigningKey::from_bytes(&other_seed)).unwrap();
let expected_pubkey = hex::encode(test_signing_key().verifying_key().to_bytes());
manifest["signatures"][0]["public_key"] = serde_json::json!(expected_pubkey);
assert!(verify_manifest_signatures(&manifest).is_err());
}
#[test]
fn empty_and_absent_signatures_are_unsigned_and_valid() {
assert_eq!(verify_manifest_signatures(&sample_manifest()).unwrap(), 0);
let mut absent = sample_manifest();
absent.as_object_mut().unwrap().remove("signatures");
assert_eq!(verify_manifest_signatures(&absent).unwrap(), 0);
}
#[test]
fn non_array_signatures_field_is_rejected() {
let mut manifest = sample_manifest();
manifest["signatures"] = serde_json::json!("signed-by-hand");
assert!(verify_manifest_signatures(&manifest).is_err());
}
#[test]
fn load_signing_key_accepts_hex_and_raw() {
let dir = tempfile::tempdir().unwrap();
let hex_path = write_hex_key(dir.path());
let raw_path = dir.path().join("signing.raw");
std::fs::write(&raw_path, test_seed()).unwrap();
let from_hex = load_signing_key(&hex_path).unwrap();
let from_raw = load_signing_key(&raw_path).unwrap();
assert_eq!(
from_hex.verifying_key().to_bytes(),
from_raw.verifying_key().to_bytes(),
"hex and raw encodings must yield the same key"
);
}
#[test]
fn load_signing_key_rejects_bad_lengths_and_bad_hex() {
let dir = tempfile::tempdir().unwrap();
let short = dir.path().join("short.key");
std::fs::write(&short, [0u8; 31]).unwrap();
assert!(load_signing_key(&short).is_err());
let bad_hex = dir.path().join("bad.hex");
std::fs::write(&bad_hex, "zz".repeat(32)).unwrap();
assert!(load_signing_key(&bad_hex).is_err());
}
#[test]
fn end_to_end_publish_signs_and_bundle_verifies() {
let dir = tempfile::tempdir().unwrap();
let workflow = dir.path().join("w.oxoflow");
std::fs::write(
&workflow,
"[workflow]\nname = \"s\"\nversion = \"1.0.0\"\n\n[[rules]]\nname = \"r1\"\noutput = [\"out.txt\"]\nshell = \"echo hi > {output[0]}\"\n",
)
.unwrap();
let bundle = dir.path().join("signed-bundle.tar.zst");
crate::commands::publish::publish_command(
workflow.clone(),
Some(bundle.clone()),
false,
None,
Some(write_hex_key(dir.path())),
)
.expect("publish --sign-key must produce a signed bundle");
let (manifest, _guard) = read_bundle_manifest(&bundle);
assert_eq!(manifest["signatures"].as_array().unwrap().len(), 1);
let unsigned = dir.path().join("unsigned-bundle.tar.gz");
crate::commands::publish::publish_command(
workflow,
Some(unsigned.clone()),
false,
Some("tar.gz".to_string()),
None,
)
.unwrap();
let (manifest, _) = read_bundle_manifest(&unsigned);
assert_eq!(verify_manifest_signatures(&manifest).unwrap(), 0);
assert_eq!(
BundleFormat::from_path(&bundle).unwrap(),
BundleFormat::TarZst
);
}
fn read_bundle_manifest(bundle: &std::path::Path) -> (serde_json::Value, tempfile::TempDir) {
let extract = tempfile::tempdir().unwrap();
let file = std::fs::File::open(bundle).unwrap();
let reader: Box<dyn std::io::Read> = match BundleFormat::from_path(bundle).unwrap() {
BundleFormat::TarZst => Box::new(zstd::stream::read::Decoder::new(file).unwrap()),
BundleFormat::TarGz => Box::new(flate2::read::GzDecoder::new(file)),
};
tar::Archive::new(reader).unpack(extract.path()).unwrap();
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(extract.path().join("manifest.json")).unwrap(),
)
.unwrap();
(manifest, extract)
}
}