|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | + |
| 3 | +//! Blanket `Authenticatable` trait for AuthContainer wrapping/unwrapping. |
| 4 | +//! |
| 5 | +//! Every `prost::Message + Default` automatically gets `AuthContainer` |
| 6 | +//! construction and extraction via the blanket impl below. This codifies |
| 7 | +//! the EVE [OBJECT-SIGNING](https://github.com/lf-edge/eve-api/blob/main/OBJECT-SIGNING.md) |
| 8 | +//! protocol — any Rust consumer of the EVE API gets this for free. |
| 9 | +//! |
| 10 | +//! # Usage |
| 11 | +//! |
| 12 | +//! ```rust,ignore |
| 13 | +//! use eve_api::crypto::Authenticatable; |
| 14 | +//! use eve_api::register::ZRegisterMsg; |
| 15 | +//! |
| 16 | +//! // Wrap: message → signed AuthContainer |
| 17 | +//! let msg = ZRegisterMsg { serial: "DEV-001".into(), ..Default::default() }; |
| 18 | +//! let container = msg.to_auth_container(&crypto)?; |
| 19 | +//! |
| 20 | +//! // Extract without verification |
| 21 | +//! let msg = ZRegisterMsg::from_auth_container(&container)?; |
| 22 | +//! |
| 23 | +//! // Extract with signature verification |
| 24 | +//! let msg = ZRegisterMsg::from_auth_container_verified(&container, cert_der, &crypto)?; |
| 25 | +//! ``` |
| 26 | +//! |
| 27 | +//! # Signing Convention |
| 28 | +//! |
| 29 | +//! The signature is computed over the raw `AuthBody.payload` bytes (the |
| 30 | +//! serialized inner message), **NOT** the serialized `AuthBody` protobuf |
| 31 | +//! wrapper. This matches the proven working behaviour of the existing |
| 32 | +//! micro-eve client and EVE Go's `signAuthData()`. |
| 33 | +//! |
| 34 | +//! # `sender_cert` Field |
| 35 | +//! |
| 36 | +//! `to_auth_container()` leaves `sender_cert` empty. Only `register()` |
| 37 | +//! needs the full cert — it mutates the container after construction: |
| 38 | +//! |
| 39 | +//! ```rust,ignore |
| 40 | +//! let mut container = msg.to_auth_container(&crypto)?; |
| 41 | +//! container.sender_cert = base64_encode(&crypto.signing_cert_pem()).into(); |
| 42 | +//! ``` |
| 43 | +
|
| 44 | +use bytes::Bytes; |
| 45 | +use prost::Message; |
| 46 | + |
| 47 | +use crate::auth::{AuthBody, AuthContainer}; |
| 48 | +use crate::common::HashAlgorithm; |
| 49 | + |
| 50 | +use super::{CryptoError, CryptoProvider}; |
| 51 | + |
| 52 | +/// Trait for fluent AuthContainer wrapping/unwrapping of protobuf messages. |
| 53 | +/// |
| 54 | +/// Automatically implemented for every `prost::Message + Default` type via |
| 55 | +/// blanket impl — no per-type boilerplate needed. |
| 56 | +pub trait Authenticatable: Message + Default + Sized { |
| 57 | + /// Wrap this message in a signed `AuthContainer`. |
| 58 | + /// |
| 59 | + /// Signs with whatever key the provider holds. The "which key" question |
| 60 | + /// is answered by which `CryptoProvider` instance you pass — no |
| 61 | + /// `SigningIdentity` enum, no runtime branching. |
| 62 | + /// |
| 63 | + /// # Arguments |
| 64 | + /// |
| 65 | + /// * `crypto` — provides signing and certificate access |
| 66 | + /// |
| 67 | + /// # Returns |
| 68 | + /// |
| 69 | + /// A fully-assembled `AuthContainer` ready to be serialized and sent. |
| 70 | + /// The `sender_cert` field is left empty — `register()` fills it in |
| 71 | + /// for the registration endpoint specifically. |
| 72 | + fn to_auth_container<C: CryptoProvider>( |
| 73 | + &self, |
| 74 | + crypto: &C, |
| 75 | + ) -> Result<AuthContainer, CryptoError> { |
| 76 | + // 1. Serialize the inner message → payload bytes |
| 77 | + let payload = self.encode_to_vec(); |
| 78 | + |
| 79 | + // 2. Sign the raw payload bytes (NOT the serialized AuthBody wrapper) |
| 80 | + let signature = crypto.sign(&payload)?; |
| 81 | + |
| 82 | + // 3. Wrap in AuthBody |
| 83 | + let auth_body = AuthBody { |
| 84 | + payload: Bytes::from(payload), |
| 85 | + }; |
| 86 | + |
| 87 | + // 4. Assemble the AuthContainer |
| 88 | + Ok(AuthContainer { |
| 89 | + protected_payload: Some(auth_body), |
| 90 | + algo: HashAlgorithm::Sha25632bytes as i32, |
| 91 | + sender_cert_hash: Bytes::copy_from_slice(crypto.signing_cert_hash()), |
| 92 | + signature_hash: Bytes::from(signature), |
| 93 | + sender_cert: Bytes::new(), // register() fills this in |
| 94 | + cipher_context: None, |
| 95 | + cipher_data: None, |
| 96 | + }) |
| 97 | + } |
| 98 | + |
| 99 | + /// Extract this message type from an `AuthContainer` without verification. |
| 100 | + /// |
| 101 | + /// Use this only when the transport layer (TLS) already guarantees |
| 102 | + /// authenticity, or when verification is handled separately. |
| 103 | + fn from_auth_container(container: &AuthContainer) -> Result<Self, CryptoError> { |
| 104 | + let payload = |
| 105 | + container |
| 106 | + .protected_payload |
| 107 | + .as_ref() |
| 108 | + .ok_or(CryptoError::AuthMissingField { |
| 109 | + field: "protected_payload", |
| 110 | + })?; |
| 111 | + Self::decode(payload.payload.as_ref()) |
| 112 | + .map_err(|e| CryptoError::parse("protobuf", format!("{e}"))) |
| 113 | + } |
| 114 | + |
| 115 | + /// Extract this message type from a verified `AuthContainer`. |
| 116 | + /// |
| 117 | + /// Verifies the ECDSA signature over the payload using the public key |
| 118 | + /// from `sender_cert_der` before extracting the message. |
| 119 | + /// |
| 120 | + /// # Arguments |
| 121 | + /// |
| 122 | + /// * `container` — the received `AuthContainer` |
| 123 | + /// * `sender_cert_der` — DER-encoded X.509 certificate of the sender |
| 124 | + /// (e.g., the controller's signing cert) |
| 125 | + /// * `crypto` — provides the `verify()` implementation |
| 126 | + fn from_auth_container_verified<C: CryptoProvider>( |
| 127 | + container: &AuthContainer, |
| 128 | + sender_cert_der: &[u8], |
| 129 | + crypto: &C, |
| 130 | + ) -> Result<Self, CryptoError> { |
| 131 | + let protected = |
| 132 | + container |
| 133 | + .protected_payload |
| 134 | + .as_ref() |
| 135 | + .ok_or(CryptoError::AuthMissingField { |
| 136 | + field: "protected_payload", |
| 137 | + })?; |
| 138 | + |
| 139 | + let valid = crypto.verify( |
| 140 | + &protected.payload, |
| 141 | + &container.signature_hash, |
| 142 | + sender_cert_der, |
| 143 | + )?; |
| 144 | + |
| 145 | + if !valid { |
| 146 | + return Err(CryptoError::SignatureMismatch); |
| 147 | + } |
| 148 | + |
| 149 | + Self::decode(protected.payload.as_ref()) |
| 150 | + .map_err(|e| CryptoError::parse("protobuf", format!("{e}"))) |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +/// Zero-cost blanket impl — every protobuf message gets this for free. |
| 155 | +impl<T> Authenticatable for T where T: Message + Default {} |
| 156 | + |
| 157 | +#[cfg(test)] |
| 158 | +mod tests { |
| 159 | + use super::*; |
| 160 | + use crate::crypto::{CertStore, DeviceCertStore, Signer}; |
| 161 | + use crate::register::ZRegisterMsg; |
| 162 | + use crate::uuid::UuidResponse; |
| 163 | + use std::fmt; |
| 164 | + |
| 165 | + // ── Mock crypto provider ─────────────────────────────────────────── |
| 166 | + |
| 167 | + struct MockCrypto { |
| 168 | + cert_store: DeviceCertStore, |
| 169 | + } |
| 170 | + |
| 171 | + impl MockCrypto { |
| 172 | + fn new() -> Self { |
| 173 | + Self { |
| 174 | + cert_store: DeviceCertStore::new(vec![0xAA; 100], vec![0x11; 32]), |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + impl fmt::Debug for MockCrypto { |
| 180 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 181 | + f.debug_struct("MockCrypto").finish() |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + impl Signer for MockCrypto { |
| 186 | + fn sign(&self, data: &[u8]) -> Result<Vec<u8>, CryptoError> { |
| 187 | + // Simple "signature": SHA-256-ish mock — just use data length |
| 188 | + Ok(vec![data.len() as u8; 64]) |
| 189 | + } |
| 190 | + |
| 191 | + fn verify( |
| 192 | + &self, |
| 193 | + data: &[u8], |
| 194 | + signature: &[u8], |
| 195 | + _cert_der: &[u8], |
| 196 | + ) -> Result<bool, CryptoError> { |
| 197 | + // Verify: signature should be 64 bytes of (data.len() as u8) |
| 198 | + Ok(signature.len() == 64 && signature[0] == data.len() as u8) |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + impl CertStore for MockCrypto { |
| 203 | + fn signing_cert_der(&self) -> &[u8] { |
| 204 | + self.cert_store.signing_cert_der() |
| 205 | + } |
| 206 | + fn signing_cert_hash(&self) -> &[u8] { |
| 207 | + self.cert_store.signing_cert_hash() |
| 208 | + } |
| 209 | + fn device_cert_der(&self) -> &[u8] { |
| 210 | + self.cert_store.device_cert_der() |
| 211 | + } |
| 212 | + fn device_cert_hash(&self) -> &[u8] { |
| 213 | + self.cert_store.device_cert_hash() |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + // ── Tests ────────────────────────────────────────────────────────── |
| 218 | + |
| 219 | + #[test] |
| 220 | + fn test_to_auth_container_basic() { |
| 221 | + let crypto = MockCrypto::new(); |
| 222 | + let msg = ZRegisterMsg { |
| 223 | + serial: "DEV-001".to_string(), |
| 224 | + ..Default::default() |
| 225 | + }; |
| 226 | + |
| 227 | + let container = msg.to_auth_container(&crypto).unwrap(); |
| 228 | + |
| 229 | + // Check structure |
| 230 | + assert!(container.protected_payload.is_some()); |
| 231 | + assert_eq!(container.algo, HashAlgorithm::Sha25632bytes as i32); |
| 232 | + assert_eq!(container.sender_cert_hash.len(), 32); |
| 233 | + assert_eq!(container.signature_hash.len(), 64); |
| 234 | + assert!(container.sender_cert.is_empty()); // not filled by to_auth_container |
| 235 | + } |
| 236 | + |
| 237 | + #[test] |
| 238 | + fn test_roundtrip_without_verify() { |
| 239 | + let crypto = MockCrypto::new(); |
| 240 | + let original = ZRegisterMsg { |
| 241 | + serial: "SN-12345".to_string(), |
| 242 | + soft_serial: "SOFT-67890".to_string(), |
| 243 | + ..Default::default() |
| 244 | + }; |
| 245 | + |
| 246 | + let container = original.to_auth_container(&crypto).unwrap(); |
| 247 | + let extracted = ZRegisterMsg::from_auth_container(&container).unwrap(); |
| 248 | + |
| 249 | + assert_eq!(extracted.serial, "SN-12345"); |
| 250 | + assert_eq!(extracted.soft_serial, "SOFT-67890"); |
| 251 | + } |
| 252 | + |
| 253 | + #[test] |
| 254 | + fn test_roundtrip_with_verify() { |
| 255 | + let crypto = MockCrypto::new(); |
| 256 | + let original = ZRegisterMsg { |
| 257 | + serial: "DEV-002".to_string(), |
| 258 | + ..Default::default() |
| 259 | + }; |
| 260 | + |
| 261 | + let container = original.to_auth_container(&crypto).unwrap(); |
| 262 | + let extracted = |
| 263 | + ZRegisterMsg::from_auth_container_verified(&container, b"sender-cert", &crypto) |
| 264 | + .unwrap(); |
| 265 | + |
| 266 | + assert_eq!(extracted.serial, "DEV-002"); |
| 267 | + } |
| 268 | + |
| 269 | + #[test] |
| 270 | + fn test_verify_fails_with_wrong_signature() { |
| 271 | + let crypto = MockCrypto::new(); |
| 272 | + let msg = ZRegisterMsg { |
| 273 | + serial: "DEV-003".to_string(), |
| 274 | + ..Default::default() |
| 275 | + }; |
| 276 | + |
| 277 | + let mut container = msg.to_auth_container(&crypto).unwrap(); |
| 278 | + // Corrupt the signature |
| 279 | + container.signature_hash = Bytes::from(vec![0xFF; 64]); |
| 280 | + |
| 281 | + let result = |
| 282 | + ZRegisterMsg::from_auth_container_verified(&container, b"sender-cert", &crypto); |
| 283 | + |
| 284 | + assert!(result.is_err()); |
| 285 | + let err = result.unwrap_err(); |
| 286 | + assert!(matches!(err, CryptoError::SignatureMismatch)); |
| 287 | + } |
| 288 | + |
| 289 | + #[test] |
| 290 | + fn test_from_auth_container_missing_payload() { |
| 291 | + let container = AuthContainer { |
| 292 | + protected_payload: None, |
| 293 | + ..Default::default() |
| 294 | + }; |
| 295 | + |
| 296 | + let result = ZRegisterMsg::from_auth_container(&container); |
| 297 | + assert!(result.is_err()); |
| 298 | + assert!(matches!( |
| 299 | + result.unwrap_err(), |
| 300 | + CryptoError::AuthMissingField { |
| 301 | + field: "protected_payload" |
| 302 | + } |
| 303 | + )); |
| 304 | + } |
| 305 | + |
| 306 | + #[test] |
| 307 | + fn test_from_auth_container_verified_missing_payload() { |
| 308 | + let crypto = MockCrypto::new(); |
| 309 | + let container = AuthContainer { |
| 310 | + protected_payload: None, |
| 311 | + ..Default::default() |
| 312 | + }; |
| 313 | + |
| 314 | + let result = ZRegisterMsg::from_auth_container_verified(&container, b"cert", &crypto); |
| 315 | + assert!(result.is_err()); |
| 316 | + assert!(matches!( |
| 317 | + result.unwrap_err(), |
| 318 | + CryptoError::AuthMissingField { |
| 319 | + field: "protected_payload" |
| 320 | + } |
| 321 | + )); |
| 322 | + } |
| 323 | + |
| 324 | + #[test] |
| 325 | + fn test_sender_cert_hash_matches_provider() { |
| 326 | + let crypto = MockCrypto::new(); |
| 327 | + let msg = ZRegisterMsg::default(); |
| 328 | + |
| 329 | + let container = msg.to_auth_container(&crypto).unwrap(); |
| 330 | + assert_eq!( |
| 331 | + container.sender_cert_hash.as_ref(), |
| 332 | + crypto.signing_cert_hash() |
| 333 | + ); |
| 334 | + } |
| 335 | + |
| 336 | + #[test] |
| 337 | + fn test_blanket_impl_works_for_any_message() { |
| 338 | + // UuidResponse is a different message type — blanket impl should work |
| 339 | + let crypto = MockCrypto::new(); |
| 340 | + let msg = UuidResponse { |
| 341 | + uuid: "test-uuid-1234".to_string(), |
| 342 | + ..Default::default() |
| 343 | + }; |
| 344 | + |
| 345 | + let container = msg.to_auth_container(&crypto).unwrap(); |
| 346 | + let extracted = UuidResponse::from_auth_container(&container).unwrap(); |
| 347 | + assert_eq!(extracted.uuid, "test-uuid-1234"); |
| 348 | + } |
| 349 | + |
| 350 | + #[test] |
| 351 | + fn test_sender_cert_empty_by_default() { |
| 352 | + let crypto = MockCrypto::new(); |
| 353 | + let msg = ZRegisterMsg::default(); |
| 354 | + |
| 355 | + let container = msg.to_auth_container(&crypto).unwrap(); |
| 356 | + assert!( |
| 357 | + container.sender_cert.is_empty(), |
| 358 | + "sender_cert should be empty — register() fills it in" |
| 359 | + ); |
| 360 | + } |
| 361 | + |
| 362 | + #[test] |
| 363 | + fn test_register_pattern_with_sender_cert() { |
| 364 | + // Simulate the register() pattern: to_auth_container + mutate sender_cert |
| 365 | + let crypto = MockCrypto::new(); |
| 366 | + let msg = ZRegisterMsg { |
| 367 | + serial: "DEV-REG".to_string(), |
| 368 | + ..Default::default() |
| 369 | + }; |
| 370 | + |
| 371 | + let mut container = msg.to_auth_container(&crypto).unwrap(); |
| 372 | + |
| 373 | + // register() would do this: |
| 374 | + use base64::Engine; |
| 375 | + let pem = crypto.signing_cert_pem(); |
| 376 | + let b64 = base64::engine::general_purpose::STANDARD.encode(&pem); |
| 377 | + container.sender_cert = Bytes::from(b64.into_bytes()); |
| 378 | + |
| 379 | + assert!(!container.sender_cert.is_empty()); |
| 380 | + |
| 381 | + // The message should still be extractable |
| 382 | + let extracted = ZRegisterMsg::from_auth_container(&container).unwrap(); |
| 383 | + assert_eq!(extracted.serial, "DEV-REG"); |
| 384 | + } |
| 385 | +} |
0 commit comments