use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use clap::Args;
use ed25519_dalek::{Signer, SigningKey};
use serde_json::json;
use crate::exit::Exit;
use crate::output::{self, Envelope};
#[derive(Debug, Args)]
pub struct SignArgs {
#[arg(long, value_name = "BASE64_OR_DASH")]
pub signing_key_b64: String,
#[arg(long, value_name = "PATH")]
pub payload: PathBuf,
#[arg(long, value_name = "PATH")]
pub output: PathBuf,
}
pub const SIGN_KEY_MALFORMED_INVARIANT: &str = "sign.signing_key_b64.malformed";
pub const SIGN_PAYLOAD_UNREADABLE_INVARIANT: &str = "sign.payload.unreadable";
pub const SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT: &str = "sign.output.already_exists";
pub const SIGN_OUTPUT_WRITE_FAILED_INVARIANT: &str = "sign.output.write_failed";
pub fn run(args: SignArgs) -> Exit {
let key_b64 = match resolve_signing_key_b64(&args.signing_key_b64) {
Ok(value) => value,
Err(exit) => {
return finish(
exit,
"signing key could not be read",
Some(SIGN_KEY_MALFORMED_INVARIANT),
&args.payload,
&args.output,
);
}
};
let seed = match decode_signing_seed(&key_b64) {
Ok(seed) => seed,
Err(detail) => {
eprintln!("cortex sign: invariant={SIGN_KEY_MALFORMED_INVARIANT} {detail}");
return finish(
Exit::PreconditionUnmet,
&detail,
Some(SIGN_KEY_MALFORMED_INVARIANT),
&args.payload,
&args.output,
);
}
};
let payload = match fs::read(&args.payload) {
Ok(bytes) => bytes,
Err(err) => {
let detail = format!("cannot read --payload `{}`: {err}", args.payload.display());
eprintln!("cortex sign: invariant={SIGN_PAYLOAD_UNREADABLE_INVARIANT} {detail}");
return finish(
Exit::PreconditionUnmet,
&detail,
Some(SIGN_PAYLOAD_UNREADABLE_INVARIANT),
&args.payload,
&args.output,
);
}
};
if args.output.exists() {
let detail = format!(
"refusing to overwrite existing --output `{}`",
args.output.display()
);
eprintln!("cortex sign: invariant={SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT} {detail}");
return finish(
Exit::PreconditionUnmet,
&detail,
Some(SIGN_OUTPUT_ALREADY_EXISTS_INVARIANT),
&args.payload,
&args.output,
);
}
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let signature = signing_key.sign(&payload);
let sig_bytes = signature.to_bytes();
if let Err(err) = fs::write(&args.output, sig_bytes) {
let detail = format!(
"cannot write detached signature to `{}`: {err}",
args.output.display()
);
eprintln!("cortex sign: invariant={SIGN_OUTPUT_WRITE_FAILED_INVARIANT} {detail}");
return finish(
Exit::Internal,
&detail,
Some(SIGN_OUTPUT_WRITE_FAILED_INVARIANT),
&args.payload,
&args.output,
);
}
if !output::json_enabled() {
println!(
"cortex sign: signed `{}` -> `{}` (64-byte Ed25519 detached signature)",
args.payload.display(),
args.output.display()
);
}
let payload_b3 = blake3_hex(&payload);
let pubkey_hex = hex_lower(verifying_key.as_bytes());
finish_ok(payload_b3, pubkey_hex, &args.payload, &args.output)
}
fn resolve_signing_key_b64(value: &str) -> Result<String, Exit> {
if value == "-" {
let mut buf = String::new();
if let Err(err) = io::stdin().read_to_string(&mut buf) {
eprintln!(
"cortex sign: invariant={SIGN_KEY_MALFORMED_INVARIANT} cannot read --signing-key-b64 from stdin: {err}"
);
return Err(Exit::PreconditionUnmet);
}
Ok(buf.trim().to_string())
} else {
Ok(value.trim().to_string())
}
}
fn decode_signing_seed(b64: &str) -> Result<[u8; 32], String> {
let raw = STANDARD
.decode(b64.as_bytes())
.map_err(|err| format!("--signing-key-b64 is not valid base64: {err}"))?;
raw.as_slice().try_into().map_err(|_| {
format!(
"--signing-key-b64 must decode to exactly 32 raw bytes (Ed25519 seed); got {}",
raw.len()
)
})
}
fn blake3_hex(bytes: &[u8]) -> String {
format!("blake3:{}", blake3::hash(bytes).to_hex())
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
fn finish_ok(
payload_blake3: String,
operator_pubkey_hex: String,
payload: &Path,
output: &Path,
) -> Exit {
if !output::json_enabled() {
return Exit::Ok;
}
let report = json!({
"command": "cortex.sign",
"payload_path": payload.display().to_string(),
"payload_blake3": payload_blake3,
"signature_path": output.display().to_string(),
"signature_bytes": 64,
"operator_pubkey_hex": operator_pubkey_hex,
"persisted": true,
});
let envelope = Envelope::new("cortex.sign", Exit::Ok, report);
output::emit(&envelope, Exit::Ok)
}
fn finish(
exit: Exit,
detail: &str,
invariant: Option<&'static str>,
payload: &Path,
output: &Path,
) -> Exit {
if !output::json_enabled() {
return exit;
}
let report = json!({
"command": "cortex.sign",
"payload_path": payload.display().to_string(),
"signature_path": output.display().to_string(),
"detail": detail,
"invariant": invariant,
"persisted": false,
});
let envelope = Envelope::new("cortex.sign", exit, report);
output::emit(&envelope, exit)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::Verifier;
fn b64_seed(seed: [u8; 32]) -> String {
STANDARD.encode(seed)
}
#[test]
fn decode_signing_seed_rejects_wrong_length() {
let short = STANDARD.encode([0u8; 16]);
let err = decode_signing_seed(&short).unwrap_err();
assert!(err.contains("32 raw bytes"), "got: {err}");
}
#[test]
fn decode_signing_seed_rejects_non_base64() {
let err = decode_signing_seed("!!! not base64 !!!").unwrap_err();
assert!(err.contains("not valid base64"), "got: {err}");
}
#[test]
fn decode_signing_seed_accepts_32_bytes() {
let seed = [9u8; 32];
let decoded = decode_signing_seed(&b64_seed(seed)).unwrap();
assert_eq!(decoded, seed);
}
#[test]
fn signed_payload_round_trips_with_verifying_key() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("payload.bin");
let signature = dir.path().join("payload.sig");
let payload_bytes = b"the operator-signed payload bytes".to_vec();
fs::write(&payload, &payload_bytes).unwrap();
let seed = [42u8; 32];
let args = SignArgs {
signing_key_b64: b64_seed(seed),
payload: payload.clone(),
output: signature.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::Ok);
let sig_bytes = fs::read(&signature).unwrap();
assert_eq!(sig_bytes.len(), 64, "Ed25519 signature must be 64 bytes");
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().unwrap();
let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
verifying_key
.verify(&payload_bytes, &sig)
.expect("signature must verify against the derived public key");
}
#[test]
fn refuses_when_output_already_exists() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("payload.bin");
let signature = dir.path().join("payload.sig");
fs::write(&payload, b"bytes").unwrap();
fs::write(&signature, b"pre-existing").unwrap();
let args = SignArgs {
signing_key_b64: b64_seed([7u8; 32]),
payload,
output: signature.clone(),
};
let exit = run(args);
assert_eq!(exit, Exit::PreconditionUnmet);
let after = fs::read(&signature).unwrap();
assert_eq!(after, b"pre-existing");
}
#[test]
fn refuses_when_signing_key_is_malformed() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("payload.bin");
fs::write(&payload, b"bytes").unwrap();
let args = SignArgs {
signing_key_b64: "definitely-not-base64-32-bytes".to_string(),
payload,
output: dir.path().join("payload.sig"),
};
let exit = run(args);
assert_eq!(exit, Exit::PreconditionUnmet);
assert!(
!dir.path().join("payload.sig").exists(),
"output must NOT be written when the signing key is rejected"
);
}
#[test]
fn refuses_when_payload_is_missing() {
let dir = tempfile::tempdir().unwrap();
let args = SignArgs {
signing_key_b64: b64_seed([1u8; 32]),
payload: dir.path().join("does-not-exist"),
output: dir.path().join("payload.sig"),
};
let exit = run(args);
assert_eq!(exit, Exit::PreconditionUnmet);
assert!(
!dir.path().join("payload.sig").exists(),
"output must NOT be written when the payload is unreadable"
);
}
}