Skip to content

Commit 9ddf638

Browse files
committed
rust: add crypto protocol traits (Signer, CertStore, CryptoProvider, Authenticatable)
Add eve_api::crypto module with protocol-level cryptographic traits for EVE Device API OBJECT-SIGNING authentication. Traits: - Signer: sign(data) -> fixed R||S, verify(data, sig, cert) -> bool - CertStore: device/signing cert access (DER + hash + PEM) - CryptoProvider: Signer + CertStore bound - Authenticatable: blanket impl for prost::Message -> AuthContainer Also adds CryptoError enum for structured error handling across crypto implementations (software, TPM, etc.). Dependencies added: thiserror 2, base64 0.22 Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
1 parent 6637f5f commit 9ddf638

File tree

8 files changed

+1289
-0
lines changed

8 files changed

+1289
-0
lines changed

rust/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ categories = ["api-bindings"]
1212
prost = "0.13"
1313
prost-types = "0.13"
1414
bytes = "1"
15+
thiserror = "2"
16+
base64 = "0.22"
1517

1618
[build-dependencies]
1719
prost-build = "0.13"

rust/src/crypto/authenticatable.rs

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
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

Comments
 (0)