diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ecf6e989..c8816e21 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -12,7 +12,7 @@ ARG PROTO_GEN_VALIDATE_VERSION=7b06248484ceeaa947e93ca2747eccf336a88ecc # v1.2.1 ARG GOOGLEAPIS_VERSION=376467058c288ad34dd7aafa892a95883e4acd0c # master ARG PROTO_DEPS_DIR=/proto/.proto_deps -RUN apk add --no-cache bash curl git make graphviz ttf-freefont +RUN apk add --no-cache bash curl git make graphviz ttf-freefont gcc musl-dev RUN arch="$(uname -m)" && \ @@ -70,3 +70,7 @@ RUN mkdir -p $(go env GOPATH) RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v${PROTOC_GEN_GO_VERSION} && mv /root/go/bin/protoc-gen-go /usr/local/bin/protoc-gen-go RUN go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@${OPENAPI_VERSION} && mv /root/go/bin/protoc-gen-openapiv2 /usr/local/bin/protoc-gen-openapiv2 RUN go install github.com/envoyproxy/protoc-gen-validate@${PROTO_GEN_VALIDATE_VERSION} && mv /root/go/bin/protoc-gen-validate /usr/local/bin/protoc-gen-validate + +# Install Rust toolchain (for prost-build based protobuf bindings) +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt +ENV PATH="/root/.cargo/bin:${PATH}" diff --git a/.gitignore b/.gitignore index 9eb17e4e..c0fe83ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .cache/ swagger/ +rust/target/ +rust/proto-deps/ diff --git a/Makefile b/Makefile index 3debbb79..c13f3bdd 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,10 @@ help: @echo " proto Build protobuf files (default target)" @echo " proto-validate-go Generate Go validation rules for protos (should be run after proto)." @echo " swagger Generate Swagger/OpenAPI documentation" + @echo " rust Build Rust protobuf bindings" + @echo " rust-test Run Rust tests" + @echo " rust-check Run Rust clippy, fmt check" + @echo " rust-clean Clean Rust build artifacts" proto-container: docker build -f .devcontainer/Dockerfile --build-arg PROTO_DEPS_DIR=$(PROTO_DEPS_DIR) -t eve-api-builder . @@ -21,7 +25,7 @@ proto-diagram: dot ./images/devconfig.dot -Tsvg -o ./images/devconfig.svg echo generated ./images/devconfig.* -.PHONY: proto-api-% proto proto-container proto-local swagger swagger-local +.PHONY: proto-api-% proto proto-container proto-local swagger swagger-local rust rust-test rust-clean rust-check proto: proto-container docker run --rm --env HOME=/tmp -v $(PWD):/src -w /src -u $$(id -u) eve-api-builder make proto-local @@ -29,9 +33,9 @@ proto: proto-container proto-validate-go: proto-container docker run --rm --env HOME=/tmp -v $(PWD):/src -w /src -u $$(id -u) eve-api-builder make proto-validate-go-local -proto-local: go go-vet python proto-diagram +proto-local: go go-vet python rust proto-diagram @echo Done building protobuf, you may want to vendor it into your packages, e.g. pkg/pillar. - @echo See ./go/README.md for more information. + @echo See ./go/README.md and ./rust/README.md for more information. go: PROTOC_OUT_OPTS=paths=source_relative: go: proto-api-go @@ -41,6 +45,20 @@ go-vet: python: proto-api-python +rust: + cd rust && PROTO_DEPS_DIR=$(PROTO_DEPS_DIR) cargo build --release + @echo Rust bindings built successfully + +rust-test: + cd rust && PROTO_DEPS_DIR=$(PROTO_DEPS_DIR) cargo test + +rust-clean: + cd rust && cargo clean + +rust-check: + cd rust && PROTO_DEPS_DIR=$(PROTO_DEPS_DIR) cargo clippy -- -D warnings + cd rust && cargo fmt --check + proto-api-%: rm -rf $*/*/; mkdir -p $* # building $@ protoc -I./proto --$(*)_out=$(PROTOC_OUT_OPTS)./$* \ diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..e6fafc3d --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "eve-api" +version = "0.1.0" +edition = "2021" +description = "Rust protobuf bindings for the EVE Device API" +repository = "https://github.com/lf-edge/eve-api" +license = "Apache-2.0" +keywords = ["eve", "api", "protobuf", "iot", "edge"] +categories = ["api-bindings"] + +[dependencies] +prost = "0.13" +prost-types = "0.13" +bytes = "1" +thiserror = "2" +base64 = "0.22" + +[build-dependencies] +prost-build = "0.13" + +[lints.clippy] +# Allow warnings from generated protobuf code +empty_docs = "allow" +doc_lazy_continuation = "allow" +doc_overindented_list_items = "allow" +large_enum_variant = "allow" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 00000000..6bbe8fde --- /dev/null +++ b/rust/README.md @@ -0,0 +1,105 @@ +# EVE API — Rust Bindings + +Rust protobuf bindings for the [EVE Device API v2](https://github.com/lf-edge/eve-api), +auto-generated at build time from `.proto` definitions using +[prost](https://github.com/tokio-rs/prost). + +This is a **pure data types** crate — it contains only generated protobuf message +structs and enums. No business logic, no crypto, no HTTP client. Higher-level +functionality belongs in companion crates (e.g., `crypto-provider`, `eve-api-client`). + +## Modules + +Each module corresponds to a protobuf package from the EVE API: + +| Module | Protobuf package | Key types | +|---|---|---| +| `common` | `org.lfedge.eve.common` | `HashAlgorithm`, `CipherBlock`, `CipherContext` | +| `auth` | `org.lfedge.eve.auth` | `AuthBody`, `AuthContainer` | +| `register` | `org.lfedge.eve.register` | `ZRegisterMsg` | +| `certs` | `org.lfedge.eve.certs` | `ZControllerCert`, `ZCert` | +| `config` | `org.lfedge.eve.config` | `EdgeDevConfig`, `ConfigRequest`, `ConfigResponse` | +| `attest` | `org.lfedge.eve.attest` | `ZAttestReq`, `ZAttestResp` | +| `info` | `org.lfedge.eve.info` | `ZInfoMsg` | +| `metrics` | `org.lfedge.eve.metrics` | `ZMetricMsg` | +| `logs` | `org.lfedge.eve.logs` | `LogBundle`, `LogEntry` | +| `flowlog` | `org.lfedge.eve.flowlog` | `FlowMessage` | +| `uuid` | `org.lfedge.eve.uuid` | `UuidRequest`, `UuidResponse` | +| `hardwarehealth` | `org.lfedge.eve.hardwarehealth` | `ZHardwareHealth` | +| `profile` | `org.lfedge.eve.profile` | `LocalProfile` | +| `nestedappinstancemetrics` | `org.lfedge.eve.nestedappinstancemetrics` | nested app inventory, logs, metrics | + +## Usage + +```rust +use eve_api::register::ZRegisterMsg; +use prost::Message; + +let msg = ZRegisterMsg { + pem_cert: bytes::Bytes::from_static(b"my-cert"), + serial: "DEVICE-001".to_string(), + soft_serial: "SOFT-001".to_string(), + ..Default::default() +}; + +// Serialize to protobuf wire format +let encoded = msg.encode_to_vec(); + +// Deserialize +let decoded = ZRegisterMsg::decode(encoded.as_slice()).unwrap(); +assert_eq!(decoded.serial, "DEVICE-001"); +``` + +## Convenience re-exports + +Commonly used types are re-exported from the crate root: + +```rust +use eve_api::{AuthBody, AuthContainer, HashAlgorithm, EdgeDevConfig, ZRegisterMsg}; +``` + +Constants: + +```rust +assert_eq!(eve_api::API_VERSION, "v2"); +assert_eq!(eve_api::API_PATH_PREFIX, "/api/v2/edgedevice"); +``` + +## How bindings are generated + +There are no pre-generated `.rs` files. The `build.rs` script compiles all +`.proto` files from `../proto/` via `prost-build` during `cargo build`. The +generated code lands in `$OUT_DIR` and is included via `include!()` macros +in `src/lib.rs`. + +If upstream adds a new `.proto` file or package, update `build.rs` and +`src/lib.rs` accordingly. + +## Building + +```sh +cargo build # generates and compiles bindings +cargo test # roundtrip and smoke tests +cargo clippy # lint (generated code warnings are suppressed in Cargo.toml) +``` + +Or from the repository root: + +```sh +make rust # build release +make rust-test # run tests +make rust-check # clippy + fmt check +``` + +## Dependencies + +| Crate | Purpose | +|---|---| +| `prost` | Protobuf runtime (derive `Message`) | +| `prost-types` | Well-known protobuf types | +| `bytes` | Zero-copy `Bytes` for binary fields | +| `prost-build` | Build-time proto compilation | + +## License + +Apache-2.0 — see [LICENSE](../LICENSE). diff --git a/rust/build.rs b/rust/build.rs new file mode 100644 index 00000000..b342d86d --- /dev/null +++ b/rust/build.rs @@ -0,0 +1,135 @@ +use prost_build::Config; +use std::path::Path; +use std::process::Command; + +fn main() -> Result<(), Box> { + let proto_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../proto"); + + if !proto_dir.exists() { + return Err(format!( + "Proto directory not found at {}. Make sure this crate lives inside the eve-api repo.", + proto_dir.display() + ) + .into()); + } + + let proto_dir_str = proto_dir.to_str().unwrap(); + + // Extra include path for third-party proto deps (e.g., validate/validate.proto + // from protoc-gen-validate, imported by register.proto). + // + // Resolution order: + // 1. PROTO_DEPS_DIR env var (set inside devcontainer → /proto/.proto_deps) + // 2. Local proto-deps/ directory (auto-fetched for local dev) + let proto_deps_dir = if let Ok(d) = std::env::var("PROTO_DEPS_DIR") { + Path::new(&d).join("protoc-gen-validate").to_path_buf() + } else { + let local = Path::new(env!("CARGO_MANIFEST_DIR")).join("proto-deps"); + let validate = local.join("validate").join("validate.proto"); + if !validate.exists() { + eprintln!("Fetching validate.proto for local build..."); + std::fs::create_dir_all(local.join("validate")).expect("create proto-deps/validate/"); + let status = Command::new("curl") + .args([ + "-sSfL", + "-o", + validate.to_str().unwrap(), + "https://raw.githubusercontent.com/bufbuild/protoc-gen-validate/v1.2.1/validate/validate.proto", + ]) + .status() + .expect("failed to run curl — install curl or use the devcontainer"); + if !status.success() { + panic!("failed to download validate.proto (exit {status})"); + } + } + local + }; + let proto_deps_str = proto_deps_dir.to_str().unwrap().to_owned(); + + let mut config = Config::new(); + + // Use Bytes for all bytes fields for zero-copy efficiency + config.bytes(["."]); + + // AuthContainer contains large binary payloads, but we still need Debug + // for prost::Message trait bound. We let prost derive Debug normally and + // accept the verbose output — users can use the {:?} alternate form or + // wrap in a custom Display if needed. + + // Proto files grouped by package, ordered so dependencies come first. + // + // The include path is the proto root directory; file paths passed to + // compile_protos are relative to that root. + let proto_files: Vec = [ + // --- evecommon (no deps on other eve protos) --- + "evecommon/evecommon.proto", + "evecommon/devmodelcommon.proto", + "evecommon/acipherinfo.proto", + "evecommon/netcmn.proto", + // --- eveuuid --- + "eveuuid/eveuuid.proto", + // --- auth (depends on evecommon) --- + "auth/auth.proto", + // --- certs (depends on evecommon) --- + "certs/certs.proto", + // --- register --- + "register/register.proto", + // --- attest (depends on certs) --- + "attest/attest.proto", + // --- config (depends on evecommon, certs, auth) --- + "config/devcommon.proto", + "config/storage.proto", + "config/vm.proto", + "config/fw.proto", + "config/scep.proto", + "config/netconfig.proto", + "config/netinst.proto", + "config/baseosconfig.proto", + "config/appconfig.proto", + "config/edgeview.proto", + "config/edge_node_cluster.proto", + "config/patch_envelope.proto", + "config/devmodel.proto", + "config/compound_devconfig.proto", + "config/devconfig.proto", + // --- info (depends on evecommon, config) --- + "info/cert.proto", + "info/hardware.proto", + "info/pnac.proto", + "info/ntpsources.proto", + "info/patch_envelope.proto", + "info/edge_node_cluster.proto", + "info/info.proto", + // --- metrics (depends on evecommon) --- + "metrics/nestedappruntimemetrics.proto", + "metrics/metrics.proto", + // --- logs --- + "logs/log.proto", + // --- flowlog --- + "flowlog/flowlog.proto", + // --- hardwarehealth --- + "hardwarehealth/hardware_health.proto", + // --- profile (depends on info, metrics, config) --- + "profile/local_profile.proto", + "profile/network.proto", + // --- proxy (depends on evecommon) --- + "proxy/scep.proto", + // --- nestedappinstancemetrics --- + "nestedappinstancemetrics/nestedappinstanceinventory.proto", + "nestedappinstancemetrics/nestedappinstancelog.proto", + "nestedappinstancemetrics/nestedappinstancemetrics.proto", + ] + .iter() + .map(|f| format!("{proto_dir_str}/{f}")) + .collect(); + + config.compile_protos(&proto_files, &[proto_dir_str, &proto_deps_str])?; + + // Rerun if any proto file changes + println!("cargo:rerun-if-changed={proto_dir_str}"); + println!("cargo:rerun-if-changed={proto_deps_str}"); + println!("cargo:rerun-if-env-changed=PROTO_DEPS_DIR"); + println!("cargo:rerun-if-changed=build.rs"); + + Ok(()) +} diff --git a/rust/src/crypto/authenticatable.rs b/rust/src/crypto/authenticatable.rs new file mode 100644 index 00000000..c8818c58 --- /dev/null +++ b/rust/src/crypto/authenticatable.rs @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Blanket `Authenticatable` trait for AuthContainer wrapping/unwrapping. +//! +//! Every `prost::Message + Default` automatically gets `AuthContainer` +//! construction and extraction via the blanket impl below. This codifies +//! the EVE [OBJECT-SIGNING](https://github.com/lf-edge/eve-api/blob/main/OBJECT-SIGNING.md) +//! protocol — any Rust consumer of the EVE API gets this for free. +//! +//! # Usage +//! +//! ```rust,ignore +//! use eve_api::crypto::Authenticatable; +//! use eve_api::register::ZRegisterMsg; +//! +//! // Wrap: message → signed AuthContainer +//! let msg = ZRegisterMsg { serial: "DEV-001".into(), ..Default::default() }; +//! let container = msg.to_auth_container(&crypto)?; +//! +//! // Extract without verification +//! let msg = ZRegisterMsg::from_auth_container(&container)?; +//! +//! // Extract with signature verification +//! let msg = ZRegisterMsg::from_auth_container_verified(&container, cert_der, &crypto)?; +//! ``` +//! +//! # Signing Convention +//! +//! The signature is computed over the raw `AuthBody.payload` bytes (the +//! serialized inner message), **NOT** the serialized `AuthBody` protobuf +//! wrapper. This matches the proven working behaviour of the existing +//! micro-eve client and EVE Go's `signAuthData()`. +//! +//! # `sender_cert` Field +//! +//! `to_auth_container()` leaves `sender_cert` empty. Only `register()` +//! needs the full cert — it mutates the container after construction: +//! +//! ```rust,ignore +//! let mut container = msg.to_auth_container(&crypto)?; +//! container.sender_cert = base64_encode(&crypto.signing_cert_pem()).into(); +//! ``` + +use bytes::Bytes; +use prost::Message; + +use crate::auth::{AuthBody, AuthContainer}; +use crate::common::HashAlgorithm; + +use super::{CryptoError, CryptoProvider}; + +/// Trait for fluent AuthContainer wrapping/unwrapping of protobuf messages. +/// +/// Automatically implemented for every `prost::Message + Default` type via +/// blanket impl — no per-type boilerplate needed. +pub trait Authenticatable: Message + Default + Sized { + /// Wrap this message in a signed `AuthContainer`. + /// + /// Signs with whatever key the provider holds. The "which key" question + /// is answered by which `CryptoProvider` instance you pass — no + /// `SigningIdentity` enum, no runtime branching. + /// + /// # Arguments + /// + /// * `crypto` — provides signing and certificate access + /// + /// # Returns + /// + /// A fully-assembled `AuthContainer` ready to be serialized and sent. + /// The `sender_cert` field is left empty — `register()` fills it in + /// for the registration endpoint specifically. + fn to_auth_container( + &self, + crypto: &C, + ) -> Result { + // 1. Serialize the inner message → payload bytes + let payload = self.encode_to_vec(); + + // 2. Sign the raw payload bytes (NOT the serialized AuthBody wrapper) + let signature = crypto.sign(&payload)?; + + // 3. Wrap in AuthBody + let auth_body = AuthBody { + payload: Bytes::from(payload), + }; + + // 4. Assemble the AuthContainer + Ok(AuthContainer { + protected_payload: Some(auth_body), + algo: HashAlgorithm::Sha25632bytes as i32, + sender_cert_hash: Bytes::copy_from_slice(crypto.signing_cert_hash()), + signature_hash: Bytes::from(signature), + sender_cert: Bytes::new(), // register() fills this in + cipher_context: None, + cipher_data: None, + }) + } + + /// Extract this message type from an `AuthContainer` without verification. + /// + /// Use this only when the transport layer (TLS) already guarantees + /// authenticity, or when verification is handled separately. + fn from_auth_container(container: &AuthContainer) -> Result { + let payload = + container + .protected_payload + .as_ref() + .ok_or(CryptoError::AuthMissingField { + field: "protected_payload", + })?; + Self::decode(payload.payload.as_ref()) + .map_err(|e| CryptoError::parse("protobuf", format!("{e}"))) + } + + /// Extract this message type from a verified `AuthContainer`. + /// + /// Verifies the ECDSA signature over the payload using the public key + /// from `sender_cert_der` before extracting the message. + /// + /// # Arguments + /// + /// * `container` — the received `AuthContainer` + /// * `sender_cert_der` — DER-encoded X.509 certificate of the sender + /// (e.g., the controller's signing cert) + /// * `crypto` — provides the `verify()` implementation + fn from_auth_container_verified( + container: &AuthContainer, + sender_cert_der: &[u8], + crypto: &C, + ) -> Result { + let protected = + container + .protected_payload + .as_ref() + .ok_or(CryptoError::AuthMissingField { + field: "protected_payload", + })?; + + let valid = crypto.verify( + &protected.payload, + &container.signature_hash, + sender_cert_der, + )?; + + if !valid { + return Err(CryptoError::SignatureMismatch); + } + + Self::decode(protected.payload.as_ref()) + .map_err(|e| CryptoError::parse("protobuf", format!("{e}"))) + } +} + +/// Zero-cost blanket impl — every protobuf message gets this for free. +impl Authenticatable for T where T: Message + Default {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{CertStore, DeviceCertStore, Signer}; + use crate::register::ZRegisterMsg; + use crate::uuid::UuidResponse; + use std::fmt; + + // ── Mock crypto provider ─────────────────────────────────────────── + + struct MockCrypto { + cert_store: DeviceCertStore, + } + + impl MockCrypto { + fn new() -> Self { + Self { + cert_store: DeviceCertStore::new(vec![0xAA; 100], vec![0x11; 32]), + } + } + } + + impl fmt::Debug for MockCrypto { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MockCrypto").finish() + } + } + + impl Signer for MockCrypto { + fn sign(&self, data: &[u8]) -> Result, CryptoError> { + // Simple "signature": SHA-256-ish mock — just use data length + Ok(vec![data.len() as u8; 64]) + } + + fn verify( + &self, + data: &[u8], + signature: &[u8], + _cert_der: &[u8], + ) -> Result { + // Verify: signature should be 64 bytes of (data.len() as u8) + Ok(signature.len() == 64 && signature[0] == data.len() as u8) + } + } + + impl CertStore for MockCrypto { + fn signing_cert_der(&self) -> &[u8] { + self.cert_store.signing_cert_der() + } + fn signing_cert_hash(&self) -> &[u8] { + self.cert_store.signing_cert_hash() + } + fn device_cert_der(&self) -> &[u8] { + self.cert_store.device_cert_der() + } + fn device_cert_hash(&self) -> &[u8] { + self.cert_store.device_cert_hash() + } + } + + // ── Tests ────────────────────────────────────────────────────────── + + #[test] + fn test_to_auth_container_basic() { + let crypto = MockCrypto::new(); + let msg = ZRegisterMsg { + serial: "DEV-001".to_string(), + ..Default::default() + }; + + let container = msg.to_auth_container(&crypto).unwrap(); + + // Check structure + assert!(container.protected_payload.is_some()); + assert_eq!(container.algo, HashAlgorithm::Sha25632bytes as i32); + assert_eq!(container.sender_cert_hash.len(), 32); + assert_eq!(container.signature_hash.len(), 64); + assert!(container.sender_cert.is_empty()); // not filled by to_auth_container + } + + #[test] + fn test_roundtrip_without_verify() { + let crypto = MockCrypto::new(); + let original = ZRegisterMsg { + serial: "SN-12345".to_string(), + soft_serial: "SOFT-67890".to_string(), + ..Default::default() + }; + + let container = original.to_auth_container(&crypto).unwrap(); + let extracted = ZRegisterMsg::from_auth_container(&container).unwrap(); + + assert_eq!(extracted.serial, "SN-12345"); + assert_eq!(extracted.soft_serial, "SOFT-67890"); + } + + #[test] + fn test_roundtrip_with_verify() { + let crypto = MockCrypto::new(); + let original = ZRegisterMsg { + serial: "DEV-002".to_string(), + ..Default::default() + }; + + let container = original.to_auth_container(&crypto).unwrap(); + let extracted = + ZRegisterMsg::from_auth_container_verified(&container, b"sender-cert", &crypto) + .unwrap(); + + assert_eq!(extracted.serial, "DEV-002"); + } + + #[test] + fn test_verify_fails_with_wrong_signature() { + let crypto = MockCrypto::new(); + let msg = ZRegisterMsg { + serial: "DEV-003".to_string(), + ..Default::default() + }; + + let mut container = msg.to_auth_container(&crypto).unwrap(); + // Corrupt the signature + container.signature_hash = Bytes::from(vec![0xFF; 64]); + + let result = + ZRegisterMsg::from_auth_container_verified(&container, b"sender-cert", &crypto); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CryptoError::SignatureMismatch)); + } + + #[test] + fn test_from_auth_container_missing_payload() { + let container = AuthContainer { + protected_payload: None, + ..Default::default() + }; + + let result = ZRegisterMsg::from_auth_container(&container); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CryptoError::AuthMissingField { + field: "protected_payload" + } + )); + } + + #[test] + fn test_from_auth_container_verified_missing_payload() { + let crypto = MockCrypto::new(); + let container = AuthContainer { + protected_payload: None, + ..Default::default() + }; + + let result = ZRegisterMsg::from_auth_container_verified(&container, b"cert", &crypto); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CryptoError::AuthMissingField { + field: "protected_payload" + } + )); + } + + #[test] + fn test_sender_cert_hash_matches_provider() { + let crypto = MockCrypto::new(); + let msg = ZRegisterMsg::default(); + + let container = msg.to_auth_container(&crypto).unwrap(); + assert_eq!( + container.sender_cert_hash.as_ref(), + crypto.signing_cert_hash() + ); + } + + #[test] + fn test_blanket_impl_works_for_any_message() { + // UuidResponse is a different message type — blanket impl should work + let crypto = MockCrypto::new(); + let msg = UuidResponse { + uuid: "test-uuid-1234".to_string(), + ..Default::default() + }; + + let container = msg.to_auth_container(&crypto).unwrap(); + let extracted = UuidResponse::from_auth_container(&container).unwrap(); + assert_eq!(extracted.uuid, "test-uuid-1234"); + } + + #[test] + fn test_sender_cert_empty_by_default() { + let crypto = MockCrypto::new(); + let msg = ZRegisterMsg::default(); + + let container = msg.to_auth_container(&crypto).unwrap(); + assert!( + container.sender_cert.is_empty(), + "sender_cert should be empty — register() fills it in" + ); + } + + #[test] + fn test_register_pattern_with_sender_cert() { + // Simulate the register() pattern: to_auth_container + mutate sender_cert + let crypto = MockCrypto::new(); + let msg = ZRegisterMsg { + serial: "DEV-REG".to_string(), + ..Default::default() + }; + + let mut container = msg.to_auth_container(&crypto).unwrap(); + + // register() would do this: + use base64::Engine; + let pem = crypto.signing_cert_pem(); + let b64 = base64::engine::general_purpose::STANDARD.encode(&pem); + container.sender_cert = Bytes::from(b64.into_bytes()); + + assert!(!container.sender_cert.is_empty()); + + // The message should still be extractable + let extracted = ZRegisterMsg::from_auth_container(&container).unwrap(); + assert_eq!(extracted.serial, "DEV-REG"); + } +} diff --git a/rust/src/crypto/cert_store.rs b/rust/src/crypto/cert_store.rs new file mode 100644 index 00000000..30fb4f54 --- /dev/null +++ b/rust/src/crypto/cert_store.rs @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Certificate storage trait for the EVE OBJECT-SIGNING protocol. +//! +//! The [`CertStore`] trait provides access to the certificates needed by +//! [`Authenticatable`](super::Authenticatable) to populate `AuthContainer` +//! fields (`sender_cert_hash`, `sender_cert`) and by `EveClient` to include +//! the device certificate in `ZRegisterMsg.pem_cert`. +//! +//! # Design +//! +//! `CertStore` is intentionally **separate from [`Signer`](super::Signer)**: +//! crypto providers generate certs and sign data; certificate storage holds +//! them. Different `CertStore` implementations serve different operational +//! phases: +//! +//! - [`OnboardCertStore`] — onboarding phase: `signing_cert` = onboard cert, +//! `device_cert` = device cert +//! - [`DeviceCertStore`] — operational phase: `signing_cert` = device cert, +//! `device_cert` = device cert +//! +//! # Infallible Access +//! +//! All methods return references (`&[u8]`), not `Result`. Certificates are +//! loaded/generated during provider construction — once a `CertStore` is +//! built, cert access is infallible. Any I/O or TPM NV reads happen before +//! the store is constructed. +//! +//! # Concrete Implementations +//! +//! `CertStore` is a trait (not a struct) to allow platform-specific backends: +//! in-memory (tests), file-based PEM (embedded Linux), TPM NV-backed (device +//! cert persisted in NV RAM), PKCS#11/HSM, etc. The concrete +//! `OnboardCertStore` and `DeviceCertStore` structs below are the simplest +//! impls — the trait keeps the door open for any backend. + +use base64::Engine; + +/// Certificate storage for a specific operational phase. +/// +/// During onboarding: `signing_cert` = onboard cert, `device_cert` = device cert. +/// During operation: `signing_cert` = device cert, `device_cert` = device cert. +/// +/// # Thread Safety +/// +/// Implementations must be `Send + Sync` so the provider can be shared +/// across async tasks. +pub trait CertStore: Send + Sync { + /// The certificate whose private key is used for signing. + /// + /// This determines what goes into `AuthContainer.sender_cert_hash`. + /// During onboarding this is the onboard cert; during operation this + /// is the device cert. + fn signing_cert_der(&self) -> &[u8]; + + /// SHA-256 hash of the signing certificate (all 32 bytes). + /// + /// Used as `AuthContainer.sender_cert_hash` with + /// `algo = SHA256_32BYTES`. + fn signing_cert_hash(&self) -> &[u8]; + + /// The signing certificate in PEM encoding. + /// + /// Default implementation PEM-wraps the DER bytes. + fn signing_cert_pem(&self) -> Vec { + pem_encode("CERTIFICATE", self.signing_cert_der()) + } + + /// The device certificate in DER encoding (always the device cert, + /// regardless of phase). + /// + /// Needed for `ZRegisterMsg.pem_cert` during onboarding. + fn device_cert_der(&self) -> &[u8]; + + /// The device certificate in PEM encoding. + /// + /// Default implementation PEM-wraps the DER bytes. + fn device_cert_pem(&self) -> Vec { + pem_encode("CERTIFICATE", self.device_cert_der()) + } + + /// SHA-256 hash of the device certificate (all 32 bytes). + fn device_cert_hash(&self) -> &[u8]; +} + +/// Encode DER bytes into PEM format with the given label. +/// +/// Produces standard PEM with 64-character base64 lines. +pub fn pem_encode(label: &str, der: &[u8]) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + + let mut pem = format!("-----BEGIN {label}-----\n"); + for chunk in b64.as_bytes().chunks(64) { + // SAFETY: base64 output is always valid UTF-8 + pem.push_str(std::str::from_utf8(chunk).unwrap()); + pem.push('\n'); + } + pem.push_str(&format!("-----END {label}-----\n")); + pem.into_bytes() +} + +// ── Concrete implementations ─────────────────────────────────────────── + +/// Onboarding phase: signs with onboard key, device cert is separate. +/// +/// `signing_cert` returns the onboard certificate. +/// `device_cert` returns the device certificate. +#[derive(Debug, Clone)] +pub struct OnboardCertStore { + onboard_cert_der: Vec, + onboard_cert_hash: Vec, + device_cert_der: Vec, + device_cert_hash: Vec, +} + +impl OnboardCertStore { + /// Create a new `OnboardCertStore` from raw DER bytes and pre-computed + /// SHA-256 hashes (32 bytes each). + pub fn new( + onboard_cert_der: Vec, + onboard_cert_hash: Vec, + device_cert_der: Vec, + device_cert_hash: Vec, + ) -> Self { + Self { + onboard_cert_der, + onboard_cert_hash, + device_cert_der, + device_cert_hash, + } + } +} + +impl CertStore for OnboardCertStore { + fn signing_cert_der(&self) -> &[u8] { + &self.onboard_cert_der + } + + fn signing_cert_hash(&self) -> &[u8] { + &self.onboard_cert_hash + } + + fn device_cert_der(&self) -> &[u8] { + &self.device_cert_der + } + + fn device_cert_hash(&self) -> &[u8] { + &self.device_cert_hash + } +} + +/// Operational phase: signs with device key, signing cert = device cert. +/// +/// Both `signing_cert` and `device_cert` return the same device certificate. +#[derive(Debug, Clone)] +pub struct DeviceCertStore { + device_cert_der: Vec, + device_cert_hash: Vec, +} + +impl DeviceCertStore { + /// Create a new `DeviceCertStore` from raw DER bytes and pre-computed + /// SHA-256 hash (32 bytes). + pub fn new(device_cert_der: Vec, device_cert_hash: Vec) -> Self { + Self { + device_cert_der, + device_cert_hash, + } + } +} + +impl CertStore for DeviceCertStore { + fn signing_cert_der(&self) -> &[u8] { + &self.device_cert_der + } + + fn signing_cert_hash(&self) -> &[u8] { + &self.device_cert_hash + } + + fn device_cert_der(&self) -> &[u8] { + &self.device_cert_der + } + + fn device_cert_hash(&self) -> &[u8] { + &self.device_cert_hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_hash(prefix: u8) -> Vec { + vec![prefix; 32] + } + + fn fake_cert(id: u8) -> Vec { + vec![id; 100] + } + + #[test] + fn test_onboard_cert_store_signing_is_onboard() { + let store = OnboardCertStore::new( + fake_cert(0xAA), + fake_hash(0x11), + fake_cert(0xBB), + fake_hash(0x22), + ); + + // signing cert = onboard cert + assert_eq!(store.signing_cert_der(), &fake_cert(0xAA)); + assert_eq!(store.signing_cert_hash(), &fake_hash(0x11)); + + // device cert = device cert + assert_eq!(store.device_cert_der(), &fake_cert(0xBB)); + assert_eq!(store.device_cert_hash(), &fake_hash(0x22)); + } + + #[test] + fn test_device_cert_store_signing_is_device() { + let store = DeviceCertStore::new(fake_cert(0xCC), fake_hash(0x33)); + + // both signing and device cert are the same + assert_eq!(store.signing_cert_der(), store.device_cert_der()); + assert_eq!(store.signing_cert_hash(), store.device_cert_hash()); + assert_eq!(store.signing_cert_der(), &fake_cert(0xCC)); + assert_eq!(store.signing_cert_hash(), &fake_hash(0x33)); + } + + #[test] + fn test_pem_encode_roundtrip() { + let der = b"hello world this is some fake DER data for testing purposes"; + let pem = pem_encode("CERTIFICATE", der); + let pem_str = std::str::from_utf8(&pem).unwrap(); + + assert!(pem_str.starts_with("-----BEGIN CERTIFICATE-----\n")); + assert!(pem_str.ends_with("-----END CERTIFICATE-----\n")); + + // Decode the base64 content back + let lines: Vec<&str> = pem_str.lines().collect(); + let b64: String = lines[1..lines.len() - 1].join(""); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&b64) + .unwrap(); + assert_eq!(decoded, der); + } +} diff --git a/rust/src/crypto/error.rs b/rust/src/crypto/error.rs new file mode 100644 index 00000000..09c2007f --- /dev/null +++ b/rust/src/crypto/error.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Protocol-level error types for cryptographic operations. +//! +//! [`CryptoError`] is the canonical error type used by the protocol traits +//! (`Signer`, `CertStore`, `Authenticatable`) defined in this crate. It +//! covers all failure modes relevant to the EVE OBJECT-SIGNING protocol +//! without depending on any specific crypto backend. +//! +//! Backend-specific errors (e.g., `ring::error::Unspecified`, `TpmError`) +//! should be mapped to `CryptoError` variants (typically `SigningFailed` +//! or `VerificationFailed`) in their respective crates via `From` impls. + +use thiserror::Error; + +/// Errors that can occur during cryptographic operations. +/// +/// This enum is intentionally free of backend-specific variants — it lives +/// in `eve-api-rs` alongside the protocol traits and must not pull in +/// dependencies like `ring` or `tss-esapi`. Backend crates convert their +/// native errors into these variants. +#[derive(Error, Debug)] +pub enum CryptoError { + // ── Key / Certificate loading ────────────────────────────────────── + /// Failed to read a certificate or key file from disk. + #[error("failed to read {kind} from {path}: {source}")] + FileRead { + kind: &'static str, + path: String, + source: std::io::Error, + }, + + /// The PEM or DER data could not be parsed. + #[error("failed to parse {kind}: {reason}")] + Parse { kind: &'static str, reason: String }, + + /// The certificate or key uses an unsupported algorithm. + /// + /// The EVE API requires ECDSA with P-256 (prime256v1 / secp256r1). + #[error("unsupported algorithm: {algorithm} (expected ECDSA P-256)")] + UnsupportedAlgorithm { algorithm: String }, + + /// A required certificate or key is missing. + #[error("missing {kind}: {detail}")] + Missing { kind: &'static str, detail: String }, + + // ── Signing ──────────────────────────────────────────────────────── + /// The signing operation failed. + /// + /// Backend crates map their native errors to this variant, e.g.: + /// - `ring::error::Unspecified` → `SigningFailed { reason: "ring: ..." }` + /// - `TpmError` → `SigningFailed { reason: "tpm: ..." }` + #[error("signing failed: {reason}")] + SigningFailed { reason: String }, + + // ── Verification ─────────────────────────────────────────────────── + /// The signature verification operation itself failed (not "invalid + /// signature", but rather an operational failure like a malformed + /// input). + #[error("verification failed: {reason}")] + VerificationFailed { reason: String }, + + /// The signature is structurally valid but does not match. + #[error("signature mismatch")] + SignatureMismatch, + + // ── Hashing ──────────────────────────────────────────────────────── + /// Hash computation failed. + #[error("hash computation failed: {reason}")] + HashFailed { reason: String }, + + // ── Certificate validation ───────────────────────────────────────── + /// Certificate chain validation failed. + #[error("certificate chain validation failed: {reason}")] + CertificateChainInvalid { reason: String }, + + /// Certificate has expired or is not yet valid. + #[error("certificate validity error: {reason}")] + CertificateValidity { reason: String }, + + /// Certificate hash mismatch (e.g., `sender_cert_hash` lookup failed). + #[error("certificate hash mismatch: expected {expected}, got {actual}")] + CertificateHashMismatch { expected: String, actual: String }, + + // ── AuthContainer ────────────────────────────────────────────────── + /// A required field in the `AuthContainer` is missing or empty. + #[error("auth container missing field: {field}")] + AuthMissingField { field: &'static str }, + + /// The `AuthContainer` uses an unsupported hash algorithm. + #[error("unsupported hash algorithm in auth container: {algo}")] + AuthUnsupportedAlgo { algo: i32 }, + + // ── Generic / catch-all ──────────────────────────────────────────── + /// Base64 decoding error. + #[error("base64 decode error: {0}")] + Base64Decode(#[from] base64::DecodeError), + + /// Any other error that doesn't fit the categories above. + #[error("{0}")] + Other(String), +} + +// ── Convenience constructors ─────────────────────────────────────────── + +impl CryptoError { + /// Create a [`CryptoError::FileRead`] from an `io::Error`. + pub fn file_read(kind: &'static str, path: impl Into, source: std::io::Error) -> Self { + Self::FileRead { + kind, + path: path.into(), + source, + } + } + + /// Create a [`CryptoError::Parse`] error. + pub fn parse(kind: &'static str, reason: impl Into) -> Self { + Self::Parse { + kind, + reason: reason.into(), + } + } + + /// Create a [`CryptoError::Missing`] error. + pub fn missing(kind: &'static str, detail: impl Into) -> Self { + Self::Missing { + kind, + detail: detail.into(), + } + } + + /// Create a [`CryptoError::SigningFailed`] error. + pub fn signing(reason: impl Into) -> Self { + Self::SigningFailed { + reason: reason.into(), + } + } + + /// Create a [`CryptoError::VerificationFailed`] error. + pub fn verification(reason: impl Into) -> Self { + Self::VerificationFailed { + reason: reason.into(), + } + } +} diff --git a/rust/src/crypto/mod.rs b/rust/src/crypto/mod.rs new file mode 100644 index 00000000..714f3231 --- /dev/null +++ b/rust/src/crypto/mod.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Cryptographic protocol traits for the EVE Device API. +//! +//! This module defines the traits that codify the +//! [OBJECT-SIGNING](https://github.com/lf-edge/eve-api/blob/main/OBJECT-SIGNING.md) +//! specification: +//! +//! - [`CryptoError`] — protocol-level error type (no backend-specific variants) +//! - [`Signer`] — sign and verify with ECDSA-P256-SHA256 +//! - [`CertStore`] — certificate access (signing cert, device cert, hashes) +//! - [`OnboardCertStore`] — `CertStore` for the onboarding phase +//! - [`DeviceCertStore`] — `CertStore` for the operational phase +//! - [`pem_encode`] — utility to PEM-wrap DER bytes +//! +//! - [`Authenticatable`] — blanket impl on `prost::Message + Default` for +//! `AuthContainer` wrapping/unwrapping + +pub mod authenticatable; +pub mod cert_store; +pub mod error; +pub mod provider; +pub mod signer; + +pub use authenticatable::Authenticatable; +pub use cert_store::{pem_encode, CertStore, DeviceCertStore, OnboardCertStore}; +pub use error::CryptoError; +pub use provider::CryptoProvider; +pub use signer::Signer; diff --git a/rust/src/crypto/provider.rs b/rust/src/crypto/provider.rs new file mode 100644 index 00000000..a55880c9 --- /dev/null +++ b/rust/src/crypto/provider.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Combined `CryptoProvider` supertrait for the EVE OBJECT-SIGNING protocol. +//! +//! [`CryptoProvider`] is the convenience bound used by +//! [`Authenticatable`](super::Authenticatable) and `EveClient` — it combines +//! [`Signer`] (signing/verification) with [`CertStore`] (certificate access) +//! into a single trait bound. +//! +//! # Blanket Implementation +//! +//! Any type that implements both `Signer` and `CertStore` is automatically a +//! `CryptoProvider` — no manual impl needed: +//! +//! ```rust,ignore +//! use eve_api::crypto::{Signer, CertStore, CryptoProvider}; +//! +//! // SoftwareCryptoProvider implements Signer + CertStore, +//! // so it's automatically a CryptoProvider. +//! fn send_request(crypto: &impl CryptoProvider) { +//! let sig = crypto.sign(b"payload").unwrap(); +//! let hash = crypto.signing_cert_hash(); +//! // ... +//! } +//! ``` +//! +//! # Design Rationale +//! +//! `Signer` and `CertStore` are independently useful — you can pass a bare +//! `Signer` to lower-level code that doesn't need certs. `CryptoProvider` is +//! the convenient combined bound for protocol-level code that needs both. + +use super::{CertStore, Signer}; + +/// Combined interface: signing + certificate access. +/// +/// This is what [`Authenticatable::to_auth_container()`](super::Authenticatable) +/// and `EveClient` are generic over. +/// +/// Implementations live in micro-eve crates (`SoftwareCryptoProvider`, +/// `TpmCryptoProvider`). Each holds a signing backend + certificate data, +/// implementing both [`Signer`] and [`CertStore`]. The blanket impl below +/// makes them automatically a `CryptoProvider`. +pub trait CryptoProvider: Signer + CertStore {} + +/// Blanket implementation: anything that is both a `Signer` and a `CertStore` +/// is automatically a `CryptoProvider`. +impl CryptoProvider for T {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{CryptoError, DeviceCertStore}; + use std::fmt; + + /// A minimal mock that implements both Signer and CertStore. + struct MockProvider { + cert_store: DeviceCertStore, + } + + impl MockProvider { + fn new() -> Self { + Self { + cert_store: DeviceCertStore::new(vec![0xAA; 100], vec![0x11; 32]), + } + } + } + + impl fmt::Debug for MockProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MockProvider").finish() + } + } + + impl Signer for MockProvider { + fn sign(&self, data: &[u8]) -> Result, CryptoError> { + Ok(vec![data.len() as u8; 64]) + } + + fn verify( + &self, + _data: &[u8], + signature: &[u8], + _cert_der: &[u8], + ) -> Result { + Ok(signature.len() == 64) + } + } + + impl CertStore for MockProvider { + fn signing_cert_der(&self) -> &[u8] { + self.cert_store.signing_cert_der() + } + fn signing_cert_hash(&self) -> &[u8] { + self.cert_store.signing_cert_hash() + } + fn device_cert_der(&self) -> &[u8] { + self.cert_store.device_cert_der() + } + fn device_cert_hash(&self) -> &[u8] { + self.cert_store.device_cert_hash() + } + } + + #[test] + fn test_blanket_impl_works() { + // MockProvider implements Signer + CertStore, so it should + // automatically be a CryptoProvider. + fn requires_crypto_provider(p: &impl CryptoProvider) -> Vec { + let sig = p.sign(b"test").unwrap(); + let _hash = p.signing_cert_hash(); + sig + } + + let provider = MockProvider::new(); + let sig = requires_crypto_provider(&provider); + assert_eq!(sig.len(), 64); + } +} diff --git a/rust/src/crypto/signer.rs b/rust/src/crypto/signer.rs new file mode 100644 index 00000000..ca4d9380 --- /dev/null +++ b/rust/src/crypto/signer.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Core signing and verification trait for the EVE OBJECT-SIGNING protocol. +//! +//! The [`Signer`] trait is the minimal interface needed by +//! [`Authenticatable`](super::Authenticatable) and `EveClient` for day-to-day +//! `AuthContainer` construction and verification. +//! +//! # Implementations +//! +//! | Implementation | Backend | Crate | +//! |--------------------|--------------|------------------| +//! | `SoftwareSigner` | `ring` | `crypto-software`| +//! | `TpmSigner` | `tss-esapi` | `tpm-provider` | +//! +//! # Signature Format +//! +//! `sign()` returns a **fixed-size R‖S** signature (64 bytes for P-256), +//! suitable for `AuthContainer.signature_hash`. This is distinct from the +//! ASN.1 DER encoding used by TLS — the conversion happens at a lower layer +//! (e.g., `rustls_signer.rs` in `tpm-provider`). +//! +//! # Verification +//! +//! `verify()` is always performed in software using the public key extracted +//! from the provided DER certificate. There is no security benefit to using +//! the TPM for verification — it only involves public keys (no secrets). +//! This matches EVE Go's `verifyAuthSig()` in `controllerconn/authen.go`, +//! which uses Go's standard `ecdsa.Verify()` regardless of TPM presence. + +use std::fmt::Debug; + +use super::CryptoError; + +/// Core signing and verification operations for the EVE OBJECT-SIGNING protocol. +/// +/// Uses concrete [`CryptoError`] (not an associated error type) so that +/// [`Authenticatable`](super::Authenticatable) and `EveClient` can use `?` +/// without `From` bounds at every call site. +/// +/// # Thread Safety +/// +/// Implementations must be `Send + Sync` so the provider can be shared +/// across async tasks (e.g., held inside an `Arc` in the API client). +/// +/// # Example +/// +/// ```rust,ignore +/// use eve_api::crypto::{Signer, CryptoError}; +/// +/// fn sign_payload(signer: &impl Signer, data: &[u8]) -> Result, CryptoError> { +/// signer.sign(data) +/// } +/// ``` +pub trait Signer: Send + Sync + Debug { + /// Sign `data` with ECDSA-P256-SHA256. + /// + /// Returns a fixed-size R‖S signature (64 bytes for P-256) suitable + /// for `AuthContainer.signature_hash`. + /// + /// The implementation hashes `data` with SHA-256 internally before + /// signing — callers pass the raw payload bytes, not a pre-computed + /// digest. + fn sign(&self, data: &[u8]) -> Result, CryptoError>; + + /// Verify an ECDSA-P256-SHA256 signature using the public key from + /// the given DER-encoded X.509 certificate. + /// + /// Returns `Ok(true)` if the signature is valid, `Ok(false)` if it is + /// structurally well-formed but does not match, or `Err` if verification + /// cannot be performed (e.g., unsupported key type, malformed cert). + /// + /// # Software-only verification + /// + /// This method is **always performed in software** — verification uses + /// only the public key (extracted from the cert), so there is no security + /// benefit to using the TPM. The TPM protects *private* keys (signing, + /// ECDH, quote); public-key verification is pure math with no secrets + /// involved. + /// + /// This matches EVE Go's `verifyAuthSig()` which uses Go's standard + /// `ecdsa.Verify()` / `rsa.VerifyPKCS1v15()` regardless of whether the + /// device has a TPM. + fn verify(&self, data: &[u8], signature: &[u8], cert_der: &[u8]) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal mock signer for testing trait bounds and usage patterns. + #[derive(Debug)] + struct MockSigner; + + impl Signer for MockSigner { + fn sign(&self, data: &[u8]) -> Result, CryptoError> { + // Return data length as a fake "signature" for testing + Ok(vec![data.len() as u8; 64]) + } + + fn verify( + &self, + _data: &[u8], + signature: &[u8], + _cert_der: &[u8], + ) -> Result { + // Accept any 64-byte signature + Ok(signature.len() == 64) + } + } + + #[test] + fn test_signer_generic_usage() { + fn sign_and_verify(s: &impl Signer, data: &[u8]) -> Result { + let sig = s.sign(data)?; + s.verify(data, &sig, b"cert") + } + + let signer = MockSigner; + assert!(sign_and_verify(&signer, b"payload").unwrap()); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 00000000..71b8c7db --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,180 @@ +//! # EVE API Rust Bindings +//! +//! This crate provides auto-generated Rust protobuf bindings for the +//! [EVE Device API](https://github.com/lf-edge/eve-api), enabling communication +//! between EVE-compatible edge devices and controllers. +//! +//! ## Overview +//! +//! This is a **pure data types** crate — it contains only the generated protobuf +//! message structs and enums with no business logic, no crypto, and no HTTP client. +//! Higher-level functionality is provided by companion crates: +//! +//! - `crypto-provider` — Traits and implementations for signing/verification +//! - `eve-api-client` — Async HTTP client for the EVE controller API +//! +//! ## Modules +//! +//! Each module corresponds to a protobuf package from the EVE API: +//! +//! - [`common`] — Shared types: `HashAlgorithm`, `CipherBlock`, `CipherContext`, etc. +//! - [`auth`] — `AuthBody`, `AuthContainer` for message signing envelopes +//! - [`register`] — `ZRegisterMsg` for device onboarding +//! - [`config`] — `EdgeDevConfig`, `ConfigRequest`, `ConfigResponse` +//! - [`certs`] — `ZControllerCert`, `ZCert` for certificate management +//! - [`attest`] — `ZAttestReq`, `ZAttestResp` for TPM attestation +//! - [`info`] — `ZInfoMsg` for device/app status reporting +//! - [`metrics`] — `ZMetricMsg` for resource usage reporting +//! - [`logs`] — `LogBundle`, `LogEntry` for device logging +//! - [`flowlog`] — `FlowMessage` for network flow statistics +//! - [`uuid`] — `UuidRequest`, `UuidResponse` +//! - [`hardwarehealth`] — `ZHardwareHealth` for hardware health reports +//! - [`profile`] — `LocalProfile` for local profile server +//! +//! ## Example +//! +//! ```rust +//! use eve_api::register::ZRegisterMsg; +//! use prost::Message; +//! +//! let msg = ZRegisterMsg { +//! pem_cert: bytes::Bytes::from_static(b"my-cert"), +//! serial: "DEVICE-001".to_string(), +//! soft_serial: "SOFT-001".to_string(), +//! ..Default::default() +//! }; +//! +//! // Serialize to protobuf wire format +//! let encoded = msg.encode_to_vec(); +//! assert!(!encoded.is_empty()); +//! +//! // Deserialize back +//! let decoded = ZRegisterMsg::decode(encoded.as_slice()).unwrap(); +//! assert_eq!(decoded.serial, "DEVICE-001"); +//! ``` + +// ── crypto ───────────────────────────────────────────────────────────── +/// Cryptographic protocol traits for OBJECT-SIGNING. +/// +/// Defines [`CryptoError`] and (future) `Signer`, `CertStore`, +/// `CryptoProvider`, and `Authenticatable` traits. +pub mod crypto; + +// ── evecommon ────────────────────────────────────────────────────────── +/// Common types shared across EVE API packages. +/// +/// Includes `HashAlgorithm`, `CipherBlock`, `CipherContext`, +/// `DiskDescription`, `RadioAccessTechnology`, and more. +pub mod common { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.common.rs")); +} + +// ── auth ─────────────────────────────────────────────────────────────── +/// Authentication envelope messages. +/// +/// The [`AuthContainer`] wraps every API request/response to provide +/// end-to-end integrity via ECDSA signatures, even through TLS MitM proxies. +pub mod auth { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.auth.rs")); +} + +// ── register ─────────────────────────────────────────────────────────── +/// Device registration (onboarding) messages. +pub mod register { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.register.rs")); +} + +// ── certs ────────────────────────────────────────────────────────────── +/// Controller certificate management messages. +pub mod certs { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.certs.rs")); +} + +// ── attest ───────────────────────────────────────────────────────────── +/// Attestation and trust-anchoring messages (TPM quotes, nonces, keys). +pub mod attest { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.attest.rs")); +} + +// ── config ───────────────────────────────────────────────────────────── +/// Device and application configuration messages. +/// +/// The central type is [`EdgeDevConfig`] which carries the complete +/// desired state for a device. +pub mod config { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.config.rs")); +} + +// ── info ─────────────────────────────────────────────────────────────── +/// Device and application status/information messages. +pub mod info { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.info.rs")); +} + +// ── metrics ──────────────────────────────────────────────────────────── +/// Device and application metrics messages. +pub mod metrics { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.metrics.rs")); +} + +// ── logs ─────────────────────────────────────────────────────────────── +/// Device and application log messages. +pub mod logs { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.logs.rs")); +} + +// ── flowlog ──────────────────────────────────────────────────────────── +/// Network flow logging messages (TCP/UDP flows, DNS lookups). +pub mod flowlog { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.flowlog.rs")); +} + +// ── eveuuid ──────────────────────────────────────────────────────────── +/// UUID request/response messages for device identity. +pub mod uuid { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.uuid.rs")); +} + +// ── hardwarehealth ───────────────────────────────────────────────────── +/// Hardware health reporting messages (ECC memory, storage, etc.). +pub mod hardwarehealth { + include!(concat!( + env!("OUT_DIR"), + "/org.lfedge.eve.hardwarehealth.rs" + )); +} + +// ── profile ──────────────────────────────────────────────────────────── +/// Local profile and network status messages for Local Operator Console. +pub mod profile { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.profile.rs")); +} + +// ── proxy ────────────────────────────────────────────────────────────── +/// SCEP proxy messages for certificate enrollment. +pub mod proxy { + include!(concat!(env!("OUT_DIR"), "/org.lfedge.eve.proxy.rs")); +} + +// ── nestedappinstancemetrics ─────────────────────────────────────────── +/// Nested (child) application instance metrics, logs, and inventory. +pub mod nestedappinstancemetrics { + include!(concat!( + env!("OUT_DIR"), + "/org.lfedge.eve.nestedappinstancemetrics.rs" + )); +} + +// ── Convenience re-exports ───────────────────────────────────────────── + +pub use auth::{AuthBody, AuthContainer}; +pub use common::{CipherBlock, CipherContext, HashAlgorithm}; +pub use config::EdgeDevConfig; +pub use crypto::CryptoError; +pub use register::ZRegisterMsg; + +/// API version implemented by this crate. +pub const API_VERSION: &str = "v2"; + +/// Base path prefix for all API endpoints. +pub const API_PATH_PREFIX: &str = "/api/v2/edgedevice";