oris_evolution_network/
signing.rs1use std::fs;
2use std::path::{Path, PathBuf};
3
4use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
5
6use crate::EvolutionEnvelope;
7
8#[derive(Debug)]
9pub enum SigningError {
10 HomeDirectoryUnavailable,
11 Io(std::io::Error),
12 InvalidKeyMaterial(&'static str),
13 InvalidHex(hex::FromHexError),
14 InvalidSignature,
15 MissingSignature,
16 ContentHashMismatch,
17}
18
19impl std::fmt::Display for SigningError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 SigningError::HomeDirectoryUnavailable => write!(f, "home directory unavailable"),
23 SigningError::Io(error) => write!(f, "io error: {error}"),
24 SigningError::InvalidKeyMaterial(message) => {
25 write!(f, "invalid key material: {message}")
26 }
27 SigningError::InvalidHex(error) => write!(f, "invalid hex: {error}"),
28 SigningError::InvalidSignature => write!(f, "invalid signature"),
29 SigningError::MissingSignature => write!(f, "missing signature"),
30 SigningError::ContentHashMismatch => write!(f, "content hash mismatch"),
31 }
32 }
33}
34
35impl std::error::Error for SigningError {}
36
37impl From<std::io::Error> for SigningError {
38 fn from(value: std::io::Error) -> Self {
39 Self::Io(value)
40 }
41}
42
43impl From<hex::FromHexError> for SigningError {
44 fn from(value: hex::FromHexError) -> Self {
45 Self::InvalidHex(value)
46 }
47}
48
49pub type SigningResult<T> = Result<T, SigningError>;
50pub type SignedEnvelope = EvolutionEnvelope;
51
52pub struct NodeKeypair {
53 signing_key: SigningKey,
54 path: PathBuf,
55}
56
57impl NodeKeypair {
58 pub fn generate() -> SigningResult<Self> {
59 let home = std::env::var_os("HOME").ok_or(SigningError::HomeDirectoryUnavailable)?;
60 let path = PathBuf::from(home).join(".oris").join("node.key");
61 Self::generate_at(path)
62 }
63
64 pub fn generate_at(path: impl AsRef<Path>) -> SigningResult<Self> {
65 let path = path.as_ref().to_path_buf();
66 if let Some(parent) = path.parent() {
67 fs::create_dir_all(parent)?;
68 }
69
70 let mut secret = [0u8; 32];
71 getrandom::getrandom(&mut secret)
72 .map_err(|_| SigningError::InvalidKeyMaterial("failed to generate randomness"))?;
73
74 let signing_key = SigningKey::from_bytes(&secret);
75 fs::write(&path, hex::encode(secret))?;
76 Ok(Self { signing_key, path })
77 }
78
79 pub fn from_path(path: impl AsRef<Path>) -> SigningResult<Self> {
80 let path = path.as_ref().to_path_buf();
81 let contents = fs::read_to_string(&path)?;
82 let secret = hex::decode(contents.trim())?;
83 let secret: [u8; 32] = secret
84 .try_into()
85 .map_err(|_| SigningError::InvalidKeyMaterial("expected 32-byte secret key"))?;
86 Ok(Self {
87 signing_key: SigningKey::from_bytes(&secret),
88 path,
89 })
90 }
91
92 pub fn public_key_hex(&self) -> String {
93 hex::encode(self.signing_key.verifying_key().to_bytes())
94 }
95
96 pub fn path(&self) -> &Path {
97 &self.path
98 }
99}
100
101pub fn sign_envelope(keypair: &NodeKeypair, envelope: &EvolutionEnvelope) -> SignedEnvelope {
102 let mut signed = envelope.clone();
103 signed.signature = None;
104 signed.content_hash = signed.compute_content_hash();
105 let signature = keypair.signing_key.sign(signed.content_hash.as_bytes());
106 signed.signature = Some(hex::encode(signature.to_bytes()));
107 signed
108}
109
110pub fn verify_envelope(
111 public_key_hex: &str,
112 signed_envelope: &SignedEnvelope,
113) -> SigningResult<()> {
114 if signed_envelope.compute_content_hash() != signed_envelope.content_hash {
115 return Err(SigningError::ContentHashMismatch);
116 }
117
118 let signature_hex = signed_envelope
119 .signature
120 .as_ref()
121 .ok_or(SigningError::MissingSignature)?;
122 let signature_bytes = hex::decode(signature_hex)?;
123 let signature_bytes: [u8; 64] = signature_bytes
124 .try_into()
125 .map_err(|_| SigningError::InvalidSignature)?;
126 let signature = Signature::from_bytes(&signature_bytes);
127
128 let public_key_bytes = hex::decode(public_key_hex)?;
129 let public_key_bytes: [u8; 32] = public_key_bytes
130 .try_into()
131 .map_err(|_| SigningError::InvalidKeyMaterial("expected 32-byte public key"))?;
132 let public_key = VerifyingKey::from_bytes(&public_key_bytes)
133 .map_err(|_| SigningError::InvalidKeyMaterial("invalid public key"))?;
134
135 public_key
136 .verify(signed_envelope.content_hash.as_bytes(), &signature)
137 .map_err(|_| SigningError::InvalidSignature)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::{EvolutionEnvelope, NetworkAsset};
144 use oris_evolution::{AssetState, Gene};
145
146 fn sample_gene(id: &str) -> Gene {
147 Gene {
148 id: id.to_string(),
149 signals: vec!["sig.test".to_string()],
150 strategy: vec!["check signature".to_string()],
151 validation: vec!["cargo test".to_string()],
152 state: AssetState::Candidate,
153 task_class_id: None,
154 }
155 }
156
157 #[test]
158 fn node_keypair_generate_persists_secret() {
159 let temp_path = std::env::temp_dir().join(format!(
160 "oris-node-key-{}.key",
161 std::time::SystemTime::now()
162 .duration_since(std::time::UNIX_EPOCH)
163 .unwrap()
164 .as_nanos()
165 ));
166 let keypair =
167 NodeKeypair::generate_at(&temp_path).expect("keypair generation should succeed");
168 assert!(temp_path.exists());
169 let loaded = NodeKeypair::from_path(&temp_path).expect("keypair should reload from disk");
170 assert_eq!(keypair.public_key_hex(), loaded.public_key_hex());
171 let _ = std::fs::remove_file(temp_path);
172 }
173
174 #[test]
175 fn sign_and_verify_round_trip_succeeds() {
176 let temp_path = std::env::temp_dir().join(format!(
177 "oris-node-key-{}.key",
178 std::time::SystemTime::now()
179 .duration_since(std::time::UNIX_EPOCH)
180 .unwrap()
181 .as_nanos()
182 ));
183 let keypair =
184 NodeKeypair::generate_at(&temp_path).expect("keypair generation should succeed");
185 let envelope = EvolutionEnvelope::publish(
186 "node-a",
187 vec![NetworkAsset::Gene {
188 gene: sample_gene("gene-sign"),
189 }],
190 );
191 let signed = sign_envelope(&keypair, &envelope);
192 assert!(verify_envelope(&keypair.public_key_hex(), &signed).is_ok());
193 let _ = std::fs::remove_file(temp_path);
194 }
195}