use anyhow::{Result, anyhow};
use chrono::Utc;
use k256::{
EncodedPoint,
ecdsa::{
Signature, SigningKey, VerifyingKey,
signature::{Signer, Verifier},
},
};
use rand_core::OsRng;
use sha2::{Digest, Sha256};
use crate::types::{InferenceResult, SignedOutput};
pub struct EcdsaSigner {
signing_key: SigningKey,
}
impl EcdsaSigner {
#[must_use]
pub fn generate() -> Self {
Self {
signing_key: SigningKey::random(&mut OsRng),
}
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let signing_key = SigningKey::from_slice(bytes)
.map_err(|e| anyhow!("invalid ECDSA private key bytes: {e}"))?;
Ok(Self { signing_key })
}
pub fn from_hex(hex_str: &str) -> Result<Self> {
let bytes = hex::decode(hex_str).map_err(|e| anyhow!("hex decode: {e}"))?;
Self::from_bytes(&bytes)
}
#[must_use]
pub fn private_key_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes().into()
}
#[must_use]
pub fn private_key_hex(&self) -> String {
hex::encode(self.private_key_bytes())
}
#[must_use]
pub fn public_key_hex(&self) -> String {
let vk = VerifyingKey::from(&self.signing_key);
hex::encode(vk.to_encoded_point(false).as_bytes())
}
#[must_use]
pub fn verifying_key_hex(&self) -> String {
let vk = VerifyingKey::from(&self.signing_key);
hex::encode(vk.to_encoded_point(true).as_bytes())
}
pub fn sign_result(&self, result: &InferenceResult) -> Result<SignedOutput> {
let payload_json =
serde_json::to_string(result).map_err(|e| anyhow!("JSON serialise: {e}"))?;
let hash_bytes = Sha256::digest(payload_json.as_bytes());
let signature: Signature = self.signing_key.sign(&hash_bytes);
let signature_hex = hex::encode(signature.to_bytes());
let payload_hash_hex = hex::encode(hash_bytes);
Ok(SignedOutput {
inference_result: result.clone(),
payload_hash_hex,
signature_hex,
public_key_hex: self.public_key_hex(),
signed_at: Utc::now(),
})
}
pub fn verify(&self, message: &[u8], signature_hex: &str) -> Result<bool> {
EcdsaVerifier::from_hex(&self.public_key_hex())?.verify(message, signature_hex)
}
pub fn verify_signed(signed: &SignedOutput) -> Result<bool> {
let payload_json = serde_json::to_string(&signed.inference_result)
.map_err(|e| anyhow!("re-serialise: {e}"))?;
let hash_bytes = Sha256::digest(payload_json.as_bytes());
EcdsaVerifier::from_hex(&signed.public_key_hex)?.verify(&hash_bytes, &signed.signature_hex)
}
}
pub struct EcdsaVerifier {
verifying_key: VerifyingKey,
}
impl EcdsaVerifier {
pub fn from_hex(hex_str: &str) -> Result<Self> {
let bytes = hex::decode(hex_str).map_err(|e| anyhow!("hex decode: {e}"))?;
let point =
EncodedPoint::from_bytes(bytes).map_err(|e| anyhow!("invalid SEC 1 point: {e}"))?;
let verifying_key = VerifyingKey::from_encoded_point(&point)
.map_err(|e| anyhow!("point not on curve: {e}"))?;
Ok(Self { verifying_key })
}
pub fn verify(&self, message: &[u8], signature_hex: &str) -> Result<bool> {
let sig_bytes = hex::decode(signature_hex).map_err(|e| anyhow!("sig hex decode: {e}"))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| anyhow!("invalid signature bytes: {e}"))?;
Ok(self.verifying_key.verify(message, &signature).is_ok())
}
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
use crate::{
sensors::fusion::SensorFusion,
types::{AlertLevel, InferenceResult},
};
fn dummy_result(seq: u64) -> InferenceResult {
let mut fusion = SensorFusion::new();
InferenceResult {
timestamp: Utc::now(),
sequence_id: seq,
fused_reading: fusion.sample(seq),
cognitive_state: "test state".to_string(),
recommendations: vec!["rec a".to_string(), "rec b".to_string()],
alert_level: AlertLevel::Normal,
raw_llm_response:
r#"{"cognitive_state":"test","alert_level":"Normal","recommendations":[]}"#
.to_string(),
}
}
#[test]
#[allow(clippy::similar_names)] fn sign_verify_roundtrip() {
let signer = EcdsaSigner::generate();
let result = dummy_result(1);
let signed = signer.sign_result(&result).unwrap();
assert!(EcdsaSigner::verify_signed(&signed).unwrap());
}
#[test]
#[allow(clippy::similar_names)]
fn tampered_cognitive_state_fails_verification() {
let signer = EcdsaSigner::generate();
let mut signed = signer.sign_result(&dummy_result(2)).unwrap();
signed.inference_result.cognitive_state = "tampered!".to_string();
assert!(!EcdsaSigner::verify_signed(&signed).unwrap());
}
#[test]
#[allow(clippy::similar_names)]
fn tampered_signature_fails_verification() {
let signer = EcdsaSigner::generate();
let mut signed = signer.sign_result(&dummy_result(3)).unwrap();
let mut sig = signed.signature_hex.clone();
let flipped = if sig.starts_with('a') { "b" } else { "a" };
sig.replace_range(0..1, flipped);
signed.signature_hex = sig;
assert!(
EcdsaSigner::verify_signed(&signed).is_err()
|| !EcdsaSigner::verify_signed(&signed).unwrap()
);
}
#[test]
fn from_hex_roundtrip_preserves_public_key() {
let original = EcdsaSigner::generate();
let hex = original.private_key_hex();
let restored = EcdsaSigner::from_hex(&hex).unwrap();
assert_eq!(original.public_key_hex(), restored.public_key_hex());
}
#[test]
#[allow(clippy::similar_names)]
fn standalone_verifier_accepts_valid_signature() {
let signer = EcdsaSigner::generate();
let result = dummy_result(4);
let signed = signer.sign_result(&result).unwrap();
let verifier = EcdsaVerifier::from_hex(&signed.public_key_hex).unwrap();
let hash = Sha256::digest(
serde_json::to_string(&signed.inference_result)
.unwrap()
.as_bytes(),
);
assert!(verifier.verify(&hash, &signed.signature_hex).unwrap());
}
#[test]
fn cross_key_verification_fails() {
let signer_a = EcdsaSigner::generate();
let signer_b = EcdsaSigner::generate();
let signed = signer_a.sign_result(&dummy_result(5)).unwrap();
let verifier_b = EcdsaVerifier::from_hex(&signer_b.public_key_hex()).unwrap();
let hash = Sha256::digest(
serde_json::to_string(&signed.inference_result)
.unwrap()
.as_bytes(),
);
assert!(!verifier_b.verify(&hash, &signed.signature_hex).unwrap());
}
#[test]
fn public_key_hex_is_130_chars_uncompressed() {
let signer = EcdsaSigner::generate();
assert_eq!(signer.public_key_hex().len(), 130);
}
#[test]
#[allow(clippy::similar_names)]
fn signature_hex_is_128_chars() {
let signer = EcdsaSigner::generate();
let result = dummy_result(6);
let signed = signer.sign_result(&result).unwrap();
assert_eq!(signed.signature_hex.len(), 128);
}
}