flaron-sdk 1.1.0

Official Rust SDK for writing Flaron edge flares - WebAssembly modules that run on the Flaron CDN edge runtime.
Documentation
//! Cryptographic primitives provided by the host.
//!
//! All operations run in the host's native crypto stack - they are *not*
//! reimplemented in Wasm. This makes them constant-time, FIPS-friendly where
//! applicable, and dramatically faster than a pure-Wasm Rust crypto build.
//!
//! ## Secret resolution
//!
//! Functions that take a `secret_key` parameter (HMAC, JWT, AES) reference
//! a *secret name*, not raw key material. The host looks the name up in the
//! flare's domain secrets store; the flare must have the secret in its
//! `allowed_secrets` allowlist or the call will fail. Secret values never
//! cross the Wasm boundary, so a flare cannot exfiltrate them.
//!
//! See [`crate::secrets`] for read access to the same store.

use std::collections::HashMap;

use crate::{ffi, mem};

/// Errors returned by the higher-level crypto wrappers.
#[derive(Debug, thiserror::Error)]
pub enum CryptoError {
    /// The host returned no result. Usually means the secret is not in the
    /// flare's allowlist, the secret name does not exist, the algorithm is
    /// not supported, or the input was invalid.
    #[error("crypto: host returned no result")]
    NoResult,

    /// The ciphertext passed to [`decrypt_aes`] was not valid base64.
    #[error("crypto: invalid base64 ciphertext")]
    BadBase64,
}

/// Compute a one-way hash of `input` using `algorithm`.
///
/// Supported algorithms (host side, see `internal/edgeops/crypto.go`):
/// `"sha256"`, `"sha512"`, `"sha1"`, `"md5"`, `"blake2b"`, `"blake2s"`.
/// Result is hex-encoded.
///
/// Returns `None` if the host failed to compute the hash (unknown algorithm,
/// invalid input).
pub fn hash(algorithm: &str, input: &str) -> Option<String> {
    let args = serde_json::json!({
        "algorithm": algorithm,
        "input": input,
    });
    let args_str = args.to_string();
    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
    let result = unsafe { ffi::crypto_hash(args_ptr, args_len) };
    // SAFETY: host writes a UTF-8 hex string into the bump arena.
    unsafe { mem::read_packed_string(result) }
}

/// Compute an HMAC over `input` using a *named* secret stored in the flare's
/// domain config.
///
/// `secret_key` is the secret's name (e.g. `"webhook-signing-key"`), not the
/// raw key material. The host enforces the flare's `allowed_secrets`
/// allowlist before performing the operation.
///
/// Returns `None` if the secret is not allowed, missing, or the host failed
/// to compute the HMAC.
pub fn hmac(secret_key: &str, input: &str) -> Option<String> {
    let args = serde_json::json!({
        "secret_key": secret_key,
        "input": input,
    });
    let args_str = args.to_string();
    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
    let result = unsafe { ffi::crypto_hmac(args_ptr, args_len) };
    // SAFETY: host writes a UTF-8 hex-encoded HMAC into the bump arena.
    unsafe { mem::read_packed_string(result) }
}

/// Sign a JWT with the named secret.
///
/// `algorithm` is one of `"HS256"`, `"HS384"`, `"HS512"`. `secret_key` is the
/// name of the HMAC secret in the flare's allowlist. `claims` becomes the
/// JWT payload - the host adds standard registered claims (`iat`, `exp`)
/// according to its policy.
///
/// Returns the compact-serialised JWT (`header.payload.signature`) on
/// success.
pub fn sign_jwt(
    algorithm: &str,
    secret_key: &str,
    claims: &HashMap<String, String>,
) -> Option<String> {
    let args = serde_json::json!({
        "algorithm": algorithm,
        "secret_key": secret_key,
        "claims": claims,
    });
    let args_str = args.to_string();
    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
    let result = unsafe { ffi::crypto_sign_jwt(args_ptr, args_len) };
    // SAFETY: host writes the JWT string into the bump arena.
    unsafe { mem::read_packed_string(result) }
}

/// Encrypt `plaintext` using AES-GCM with the named secret as the key.
///
/// Returns the base64-encoded ciphertext (nonce prepended, host format).
/// Decrypt with [`decrypt_aes`].
pub fn encrypt_aes(secret_key: &str, plaintext: &str) -> Result<String, CryptoError> {
    let args = serde_json::json!({
        "secret_key": secret_key,
        "input": plaintext,
    });
    let args_str = args.to_string();
    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
    let result = unsafe { ffi::crypto_encrypt_aes(args_ptr, args_len) };
    // SAFETY: host writes a base64 ciphertext string into the bump arena.
    unsafe { mem::read_packed_string(result) }.ok_or(CryptoError::NoResult)
}

/// Decrypt a base64-encoded AES-GCM ciphertext produced by [`encrypt_aes`].
///
/// `ciphertext_b64` must be the same string [`encrypt_aes`] returned. Returns
/// the recovered plaintext bytes.
pub fn decrypt_aes(secret_key: &str, ciphertext_b64: &str) -> Result<Vec<u8>, CryptoError> {
    let args = serde_json::json!({
        "secret_key": secret_key,
        "input": ciphertext_b64,
    });
    let args_str = args.to_string();
    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
    let result = unsafe { ffi::crypto_decrypt_aes(args_ptr, args_len) };
    // SAFETY: host writes the decrypted plaintext bytes into the bump arena.
    unsafe { mem::read_packed_bytes(result) }.ok_or(CryptoError::NoResult)
}

/// Errors returned by [`random_bytes`].
#[derive(Debug, thiserror::Error)]
pub enum RandomBytesError {
    /// The host returned no result.
    #[error("random_bytes: host returned no data")]
    NoData,

    /// The host returned a malformed hex string. Treat as a hard failure -
    /// silently zero-filling would weaken any token derived from it.
    #[error("random_bytes: host returned malformed hex")]
    BadHex,
}

/// Generate cryptographically secure random bytes.
///
/// `length` is the number of bytes to return; the host caps it at `256` and
/// defaults to `32` for non-positive values. Returns the raw bytes (the
/// host's hex encoding is decoded for you).
///
/// Returns an error if the host returned no data or the hex string was
/// malformed - callers MUST treat this as a hard failure for security.
pub fn random_bytes(length: u32) -> Result<Vec<u8>, RandomBytesError> {
    let result = unsafe { ffi::crypto_random_bytes(length as i32) };
    // SAFETY: host writes a UTF-8 hex string into the bump arena.
    let hex_str = unsafe { mem::read_packed_string(result) }.ok_or(RandomBytesError::NoData)?;
    mem::hex_decode(&hex_str).ok_or(RandomBytesError::BadHex)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ffi::test_host;

    fn parse_args(s: &str) -> serde_json::Value {
        serde_json::from_str(s).expect("captured args must be valid JSON")
    }

    #[test]
    fn hash_constructs_args_and_returns_response() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_hash_response = Some("abcdef1234".into());
        });
        let result = hash("sha256", "hello world").unwrap();
        assert_eq!(result, "abcdef1234");

        let captured = test_host::read_mock(|m| m.last_crypto_hash_args.clone()).unwrap();
        let args = parse_args(&captured);
        assert_eq!(args["algorithm"], "sha256");
        assert_eq!(args["input"], "hello world");
    }

    #[test]
    fn hash_returns_none_when_host_empty() {
        test_host::reset();
        assert!(hash("sha256", "x").is_none());
    }

    #[test]
    fn hmac_constructs_args() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_hmac_response = Some("deadbeef".into());
        });
        let mac = hmac("signing-key", "payload").unwrap();
        assert_eq!(mac, "deadbeef");

        let captured = test_host::read_mock(|m| m.last_crypto_hmac_args.clone()).unwrap();
        let args = parse_args(&captured);
        assert_eq!(args["secret_key"], "signing-key");
        assert_eq!(args["input"], "payload");
    }

    #[test]
    fn sign_jwt_serializes_claims() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_sign_jwt_response = Some("h.p.s".into());
        });
        let mut claims = HashMap::new();
        claims.insert("sub".to_string(), "user-42".to_string());
        claims.insert("aud".to_string(), "api".to_string());

        let jwt = sign_jwt("HS256", "session-key", &claims).unwrap();
        assert_eq!(jwt, "h.p.s");

        let captured = test_host::read_mock(|m| m.last_crypto_sign_jwt_args.clone()).unwrap();
        let args = parse_args(&captured);
        assert_eq!(args["algorithm"], "HS256");
        assert_eq!(args["secret_key"], "session-key");
        assert_eq!(args["claims"]["sub"], "user-42");
        assert_eq!(args["claims"]["aud"], "api");
    }

    #[test]
    fn encrypt_aes_returns_ciphertext() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_encrypt_aes_response = Some("base64ciphertext==".into());
        });
        let ct = encrypt_aes("key", "plaintext").unwrap();
        assert_eq!(ct, "base64ciphertext==");

        let captured = test_host::read_mock(|m| m.last_crypto_encrypt_aes_args.clone()).unwrap();
        let args = parse_args(&captured);
        assert_eq!(args["secret_key"], "key");
        assert_eq!(args["input"], "plaintext");
    }

    #[test]
    fn encrypt_aes_no_result_is_error() {
        test_host::reset();
        match encrypt_aes("key", "x").unwrap_err() {
            CryptoError::NoResult => {}
            other => panic!("expected NoResult, got {:?}", other),
        }
    }

    #[test]
    fn decrypt_aes_returns_plaintext_bytes() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_decrypt_aes_response = Some(b"plaintext".to_vec());
        });
        let pt = decrypt_aes("key", "base64ct").unwrap();
        assert_eq!(pt, b"plaintext");
    }

    #[test]
    fn decrypt_aes_no_result_is_error() {
        test_host::reset();
        match decrypt_aes("key", "x").unwrap_err() {
            CryptoError::NoResult => {}
            other => panic!("expected NoResult, got {:?}", other),
        }
    }

    #[test]
    fn random_bytes_decodes_hex() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_random_bytes_response = Some("deadbeef".into());
        });
        let bytes = random_bytes(4).unwrap();
        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
        assert_eq!(
            test_host::read_mock(|m| m.last_random_bytes_length),
            Some(4)
        );
    }

    #[test]
    fn random_bytes_no_data_is_error() {
        test_host::reset();
        match random_bytes(8).unwrap_err() {
            RandomBytesError::NoData => {}
            other => panic!("expected NoData, got {:?}", other),
        }
    }

    #[test]
    fn random_bytes_bad_hex_is_error() {
        test_host::reset();
        test_host::with_mock(|m| {
            m.crypto_random_bytes_response = Some("xxxx".into());
        });
        match random_bytes(2).unwrap_err() {
            RandomBytesError::BadHex => {}
            other => panic!("expected BadHex, got {:?}", other),
        }
    }
}