Crypto Client API Ergonomics Review

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.


Current Call-Site Experience

// 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]

Issues

1. Raw handle threading (high friction)

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).

2. Free functions — no discoverability

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.

3. Caller-managed output buffers with size constants

For fixed-output operations (hash, HMAC, sign), the caller must:

  1. Import the size constant (SHA256_OUTPUT_SIZE)
  2. Declare a zeroed buffer of that size
  3. Pass a mutable reference

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.

4. AES-GCM returns raw usize

let 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.

5. ECDSA verify: Result<bool> is a tri-state

let 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.

6. Error type is discarded at every call site

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.


Recommendations

R1: Introduce 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).

R2: Return fixed-size arrays for hash/HMAC/sign (priority: high)

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.

R3: AES-GCM writes into caller buffer, returns &[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.

R4: Verify returns 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<()>.

R5: Keep free functions as thin wrappers (priority: low, optional)

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.

R6: Consider 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"),
        }
    }
}

Proposed Call-Site Experience (After)

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

Scope of Changes

FileChangeRisk
client/src/lib.rsAdd CryptoClient struct, convert free fns to methods, return arraysMedium — all internals rewritten but wire protocol unchanged
tests/src/main.rsUpdate call sites to use CryptoClientLow — straightforward migration
server/src/main.rsChange verify response from [0x01]/[0x00] to success/errorLow — 4 lines
api/src/protocol.rsNo changeNone
api/src/backend.rsNo changeNone

Wire protocol is unchanged. The request/response header format stays the same. Only the client-side API surface and the verify response convention change.


Migration Path

  1. Add CryptoClient struct with methods that delegate to existing internal functions
  2. Convert internal functions (hash_op, hmac_op, cipher_op) to return values instead of writing to output params
  3. Change verify to return Result<()>
  4. Update test call sites
  5. Deprecate (or remove) free functions
  6. Build and run QEMU tests to verify