Date: 2026-02-13
Scope: services/crypto/client/src/lib.rs and its callers
Status: Current API works, passes all 7 QEMU tests. This review proposes improvements.
// Today: caller must import constants, manage output buffers, thread handles use crypto_client::{sha256, hmac_sha256, aes_gcm_encrypt, aes_gcm_decrypt}; use crypto_api::{SHA256_OUTPUT_SIZE, SHA384_OUTPUT_SIZE}; let mut hash = [0u8; SHA256_OUTPUT_SIZE]; sha256(handle::CRYPTO, data, &mut hash).map_err(|_| Error::Internal)?; let mut mac = [0u8; SHA256_OUTPUT_SIZE]; hmac_sha256(handle::CRYPTO, key, data, &mut mac).map_err(|_| Error::Internal)?; let mut ct = [0u8; 64]; let ct_len = aes_gcm_encrypt(handle::CRYPTO, &key, &nonce, pt, &mut ct) .map_err(|_| Error::Internal)?; // caller must track ct_len and slice ct[..ct_len]
Every function takes handle: u32 as the first argument. The handle is always handle::CRYPTO — an IPC channel obtained once at process startup and never changed. Yet every call site must pass it explicitly.
Impact: Boilerplate, easy to pass the wrong handle, no type safety (any u32 compiles).
The API is 11 free functions (sha256, sha384, sha512, hmac_sha256, ...). A caller can't type crypto. and see the full API surface. They must know the function names and import them individually.
For fixed-output operations (hash, HMAC, sign), the caller must:
SHA256_OUTPUT_SIZE)This is 3 lines of ceremony for what should be 1 line. The output size is statically known from the operation — the API should encode that.
usizelet ct_len = aes_gcm_encrypt(..., &mut ct)?; // What is ct[ct_len..]? Garbage. Caller must remember to slice.
The returned length is easy to lose or misuse. There's no type-level connection between the output buffer and the valid region.
Result<bool> is a tri-statelet valid: bool = ecdsa_p256_verify(...)?; if !valid { ... }
Three states: Ok(true), Ok(false), Err(...). Callers must handle both the Result and the bool. In every crypto library I've seen (ring, RustCrypto, OpenSSL), verify returns Result<()> — failure is the error.
sha256(...).map_err(|_| Error::Internal)?; // ^^^^^^^^ every single call erases the error
ClientError has good variants (ServerError(CryptoError), InvalidResponse, etc.) but no caller uses them. The error type is doing work at the library level but providing no value at the application level.
CryptoClient struct (priority: high)/// Typed handle to the crypto server. /// /// Constructed once per process, stores the IPC channel handle. /// All operations are methods on this type. pub struct CryptoClient { handle: u32, } impl CryptoClient { /// Bind to the crypto server channel. pub const fn new(handle: u32) -> Self { Self { handle } } }
Why: Single construction point. Handle stored once. Full API discoverable via client. autocomplete. Zero runtime cost (the struct is a u32 wrapper).
impl CryptoClient { /// Compute SHA-256 digest. Returns the 32-byte hash. pub fn sha256(&self, data: &[u8]) -> Result<[u8; 32], ClientError> { ... } /// Compute SHA-384 digest. Returns the 48-byte hash. pub fn sha384(&self, data: &[u8]) -> Result<[u8; 48], ClientError> { ... } /// Compute HMAC-SHA256. Returns the 32-byte tag. pub fn hmac_sha256(&self, key: &[u8], data: &[u8]) -> Result<[u8; 32], ClientError> { ... } }
Why: No caller-side buffer management. No size constant imports. The return type documents the output size. On Cortex-M, returning [u8; 32] by value is cheap (registers + small stack copy). Even [u8; 64] (SHA-512) is fine — the IPC buffer copy already dominates.
Internal implementation: The existing parse_response writes into a [u8; N] — just move the buffer into the function and return it.
&[u8] or length (priority: medium)AES-GCM can't return a fixed-size array because the output size depends on input. Two options:
Option A — return written length (current approach, minimal change):
pub fn aes256_gcm_seal( &self, key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8], out: &mut [u8], // must be >= plaintext.len() + 16 ) -> Result<usize, ClientError> { ... }
Option B — write into a SealedBuf wrapper (more type-safe):
pub struct SealedOutput<'a> { buf: &'a [u8], } impl<'a> SealedOutput<'a> { pub fn as_bytes(&self) -> &[u8] { self.buf } pub fn len(&self) -> usize { self.buf.len() } }
Recommend Option A for now (no_std simplicity), but rename to seal/open which is the standard AEAD terminology.
Result<()>, not Result<bool> (priority: high)#[cfg(feature = "ecdsa")] impl CryptoClient { /// Verify an ECDSA P-256 signature. /// /// Returns `Ok(())` if the signature is valid. /// Returns `Err(ClientError::ServerError(VerificationFailed))` if invalid. pub fn ecdsa_p256_verify( &self, public_key: &[u8], message: &[u8], signature: &[u8; 64], ) -> Result<(), ClientError> { ... } }
Why: Matches every major crypto library (ring, RustCrypto, OpenSSL, BoringSSL). The current Ok(false) path maps directly to Err(VerificationFailed). Callers just write client.ecdsa_p256_verify(...)?; — one line, no boolean check.
Server-side change required: The server currently returns a 1-byte 0x01/0x00 result. Change it to return an empty success response (result_len=0) for valid, or a VerificationFailed error response for invalid. The client maps both to Result<()>.
For backward compatibility or one-off use, keep the free functions but have them delegate to CryptoClient:
/// Convenience: compute SHA-256 without constructing a client. pub fn sha256(handle: u32, data: &[u8]) -> Result<[u8; 32], ClientError> { CryptoClient::new(handle).sha256(data) }
This is zero-cost (inlined away) and avoids breaking existing callers during migration.
impl Display for ClientError (priority: low)Currently the test code does .map_err(|_| Error::Internal) everywhere. If ClientError implemented a human-readable description, callers could log it before converting:
impl core::fmt::Display for ClientError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::IpcError(e) => write!(f, "IPC: {:?}", e), Self::ServerError(e) => write!(f, "server: {:?}", e), Self::InvalidResponse => write!(f, "malformed response"), Self::BufferTooSmall => write!(f, "buffer too small"), } } }
use crypto_client::CryptoClient; let crypto = CryptoClient::new(handle::CRYPTO); // Hash — one line, no buffer, no size constant let hash = crypto.sha256(b"hello world")?; // HMAC — returns tag directly let tag = crypto.hmac_sha256(key, data)?; // AEAD let mut ct = [0u8; 64]; let ct_len = crypto.aes256_gcm_seal(&key, &nonce, plaintext, &mut ct)?; let mut pt = [0u8; 64]; let pt_len = crypto.aes256_gcm_open(&key, &nonce, &ct[..ct_len], &mut pt)?; // ECDSA — verify is Result<()>, invalid = error let sig = crypto.ecdsa_p256_sign(&private_key, message)?; crypto.ecdsa_p256_verify(&public_key, message, &sig)?; // Err on invalid
| File | Change | Risk |
|---|---|---|
client/src/lib.rs | Add CryptoClient struct, convert free fns to methods, return arrays | Medium — all internals rewritten but wire protocol unchanged |
tests/src/main.rs | Update call sites to use CryptoClient | Low — straightforward migration |
server/src/main.rs | Change verify response from [0x01]/[0x00] to success/error | Low — 4 lines |
api/src/protocol.rs | No change | None |
api/src/backend.rs | No change | None |
Wire protocol is unchanged. The request/response header format stays the same. Only the client-side API surface and the verify response convention change.
CryptoClient struct with methods that delegate to existing internal functionshash_op, hmac_op, cipher_op) to return values instead of writing to output paramsResult<()>