Skip to main content

flaron_sdk/
crypto.rs

1//! Cryptographic primitives provided by the host.
2//!
3//! All operations run in the host's native crypto stack - they are *not*
4//! reimplemented in Wasm. This makes them constant-time, FIPS-friendly where
5//! applicable, and dramatically faster than a pure-Wasm Rust crypto build.
6//!
7//! ## Secret resolution
8//!
9//! Functions that take a `secret_key` parameter (HMAC, JWT, AES) reference
10//! a *secret name*, not raw key material. The host looks the name up in the
11//! flare's domain secrets store; the flare must have the secret in its
12//! `allowed_secrets` allowlist or the call will fail. Secret values never
13//! cross the Wasm boundary, so a flare cannot exfiltrate them.
14//!
15//! See [`crate::secrets`] for read access to the same store.
16
17use std::collections::HashMap;
18
19use crate::{ffi, mem};
20
21/// Errors returned by the higher-level crypto wrappers.
22#[derive(Debug, thiserror::Error)]
23pub enum CryptoError {
24    /// The host returned no result. Usually means the secret is not in the
25    /// flare's allowlist, the secret name does not exist, the algorithm is
26    /// not supported, or the input was invalid.
27    #[error("crypto: host returned no result")]
28    NoResult,
29
30    /// The ciphertext passed to [`decrypt_aes`] was not valid base64.
31    #[error("crypto: invalid base64 ciphertext")]
32    BadBase64,
33}
34
35/// Compute a one-way hash of `input` using `algorithm`.
36///
37/// Supported algorithms (host side, see `internal/edgeops/crypto.go`):
38/// `"sha256"`, `"sha512"`, `"sha1"`, `"md5"`, `"blake2b"`, `"blake2s"`.
39/// Result is hex-encoded.
40///
41/// Returns `None` if the host failed to compute the hash (unknown algorithm,
42/// invalid input).
43pub fn hash(algorithm: &str, input: &str) -> Option<String> {
44    let args = serde_json::json!({
45        "algorithm": algorithm,
46        "input": input,
47    });
48    let args_str = args.to_string();
49    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
50    let result = unsafe { ffi::crypto_hash(args_ptr, args_len) };
51    // SAFETY: host writes a UTF-8 hex string into the bump arena.
52    unsafe { mem::read_packed_string(result) }
53}
54
55/// Compute an HMAC over `input` using a *named* secret stored in the flare's
56/// domain config.
57///
58/// `secret_key` is the secret's name (e.g. `"webhook-signing-key"`), not the
59/// raw key material. The host enforces the flare's `allowed_secrets`
60/// allowlist before performing the operation.
61///
62/// Returns `None` if the secret is not allowed, missing, or the host failed
63/// to compute the HMAC.
64pub fn hmac(secret_key: &str, input: &str) -> Option<String> {
65    let args = serde_json::json!({
66        "secret_key": secret_key,
67        "input": input,
68    });
69    let args_str = args.to_string();
70    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
71    let result = unsafe { ffi::crypto_hmac(args_ptr, args_len) };
72    // SAFETY: host writes a UTF-8 hex-encoded HMAC into the bump arena.
73    unsafe { mem::read_packed_string(result) }
74}
75
76/// Sign a JWT with the named secret.
77///
78/// `algorithm` is one of `"HS256"`, `"HS384"`, `"HS512"`. `secret_key` is the
79/// name of the HMAC secret in the flare's allowlist. `claims` becomes the
80/// JWT payload - the host adds standard registered claims (`iat`, `exp`)
81/// according to its policy.
82///
83/// Returns the compact-serialised JWT (`header.payload.signature`) on
84/// success.
85pub fn sign_jwt(
86    algorithm: &str,
87    secret_key: &str,
88    claims: &HashMap<String, String>,
89) -> Option<String> {
90    let args = serde_json::json!({
91        "algorithm": algorithm,
92        "secret_key": secret_key,
93        "claims": claims,
94    });
95    let args_str = args.to_string();
96    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
97    let result = unsafe { ffi::crypto_sign_jwt(args_ptr, args_len) };
98    // SAFETY: host writes the JWT string into the bump arena.
99    unsafe { mem::read_packed_string(result) }
100}
101
102/// Encrypt `plaintext` using AES-GCM with the named secret as the key.
103///
104/// Returns the base64-encoded ciphertext (nonce prepended, host format).
105/// Decrypt with [`decrypt_aes`].
106pub fn encrypt_aes(secret_key: &str, plaintext: &str) -> Result<String, CryptoError> {
107    let args = serde_json::json!({
108        "secret_key": secret_key,
109        "input": plaintext,
110    });
111    let args_str = args.to_string();
112    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
113    let result = unsafe { ffi::crypto_encrypt_aes(args_ptr, args_len) };
114    // SAFETY: host writes a base64 ciphertext string into the bump arena.
115    unsafe { mem::read_packed_string(result) }.ok_or(CryptoError::NoResult)
116}
117
118/// Decrypt a base64-encoded AES-GCM ciphertext produced by [`encrypt_aes`].
119///
120/// `ciphertext_b64` must be the same string [`encrypt_aes`] returned. Returns
121/// the recovered plaintext bytes.
122pub fn decrypt_aes(secret_key: &str, ciphertext_b64: &str) -> Result<Vec<u8>, CryptoError> {
123    let args = serde_json::json!({
124        "secret_key": secret_key,
125        "input": ciphertext_b64,
126    });
127    let args_str = args.to_string();
128    let (args_ptr, args_len) = mem::host_arg_str(&args_str);
129    let result = unsafe { ffi::crypto_decrypt_aes(args_ptr, args_len) };
130    // SAFETY: host writes the decrypted plaintext bytes into the bump arena.
131    unsafe { mem::read_packed_bytes(result) }.ok_or(CryptoError::NoResult)
132}
133
134/// Errors returned by [`random_bytes`].
135#[derive(Debug, thiserror::Error)]
136pub enum RandomBytesError {
137    /// The host returned no result.
138    #[error("random_bytes: host returned no data")]
139    NoData,
140
141    /// The host returned a malformed hex string. Treat as a hard failure -
142    /// silently zero-filling would weaken any token derived from it.
143    #[error("random_bytes: host returned malformed hex")]
144    BadHex,
145}
146
147/// Generate cryptographically secure random bytes.
148///
149/// `length` is the number of bytes to return; the host caps it at `256` and
150/// defaults to `32` for non-positive values. Returns the raw bytes (the
151/// host's hex encoding is decoded for you).
152///
153/// Returns an error if the host returned no data or the hex string was
154/// malformed - callers MUST treat this as a hard failure for security.
155pub fn random_bytes(length: u32) -> Result<Vec<u8>, RandomBytesError> {
156    let result = unsafe { ffi::crypto_random_bytes(length as i32) };
157    // SAFETY: host writes a UTF-8 hex string into the bump arena.
158    let hex_str = unsafe { mem::read_packed_string(result) }.ok_or(RandomBytesError::NoData)?;
159    mem::hex_decode(&hex_str).ok_or(RandomBytesError::BadHex)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::ffi::test_host;
166
167    fn parse_args(s: &str) -> serde_json::Value {
168        serde_json::from_str(s).expect("captured args must be valid JSON")
169    }
170
171    #[test]
172    fn hash_constructs_args_and_returns_response() {
173        test_host::reset();
174        test_host::with_mock(|m| {
175            m.crypto_hash_response = Some("abcdef1234".into());
176        });
177        let result = hash("sha256", "hello world").unwrap();
178        assert_eq!(result, "abcdef1234");
179
180        let captured = test_host::read_mock(|m| m.last_crypto_hash_args.clone()).unwrap();
181        let args = parse_args(&captured);
182        assert_eq!(args["algorithm"], "sha256");
183        assert_eq!(args["input"], "hello world");
184    }
185
186    #[test]
187    fn hash_returns_none_when_host_empty() {
188        test_host::reset();
189        assert!(hash("sha256", "x").is_none());
190    }
191
192    #[test]
193    fn hmac_constructs_args() {
194        test_host::reset();
195        test_host::with_mock(|m| {
196            m.crypto_hmac_response = Some("deadbeef".into());
197        });
198        let mac = hmac("signing-key", "payload").unwrap();
199        assert_eq!(mac, "deadbeef");
200
201        let captured = test_host::read_mock(|m| m.last_crypto_hmac_args.clone()).unwrap();
202        let args = parse_args(&captured);
203        assert_eq!(args["secret_key"], "signing-key");
204        assert_eq!(args["input"], "payload");
205    }
206
207    #[test]
208    fn sign_jwt_serializes_claims() {
209        test_host::reset();
210        test_host::with_mock(|m| {
211            m.crypto_sign_jwt_response = Some("h.p.s".into());
212        });
213        let mut claims = HashMap::new();
214        claims.insert("sub".to_string(), "user-42".to_string());
215        claims.insert("aud".to_string(), "api".to_string());
216
217        let jwt = sign_jwt("HS256", "session-key", &claims).unwrap();
218        assert_eq!(jwt, "h.p.s");
219
220        let captured = test_host::read_mock(|m| m.last_crypto_sign_jwt_args.clone()).unwrap();
221        let args = parse_args(&captured);
222        assert_eq!(args["algorithm"], "HS256");
223        assert_eq!(args["secret_key"], "session-key");
224        assert_eq!(args["claims"]["sub"], "user-42");
225        assert_eq!(args["claims"]["aud"], "api");
226    }
227
228    #[test]
229    fn encrypt_aes_returns_ciphertext() {
230        test_host::reset();
231        test_host::with_mock(|m| {
232            m.crypto_encrypt_aes_response = Some("base64ciphertext==".into());
233        });
234        let ct = encrypt_aes("key", "plaintext").unwrap();
235        assert_eq!(ct, "base64ciphertext==");
236
237        let captured = test_host::read_mock(|m| m.last_crypto_encrypt_aes_args.clone()).unwrap();
238        let args = parse_args(&captured);
239        assert_eq!(args["secret_key"], "key");
240        assert_eq!(args["input"], "plaintext");
241    }
242
243    #[test]
244    fn encrypt_aes_no_result_is_error() {
245        test_host::reset();
246        match encrypt_aes("key", "x").unwrap_err() {
247            CryptoError::NoResult => {}
248            other => panic!("expected NoResult, got {:?}", other),
249        }
250    }
251
252    #[test]
253    fn decrypt_aes_returns_plaintext_bytes() {
254        test_host::reset();
255        test_host::with_mock(|m| {
256            m.crypto_decrypt_aes_response = Some(b"plaintext".to_vec());
257        });
258        let pt = decrypt_aes("key", "base64ct").unwrap();
259        assert_eq!(pt, b"plaintext");
260    }
261
262    #[test]
263    fn decrypt_aes_no_result_is_error() {
264        test_host::reset();
265        match decrypt_aes("key", "x").unwrap_err() {
266            CryptoError::NoResult => {}
267            other => panic!("expected NoResult, got {:?}", other),
268        }
269    }
270
271    #[test]
272    fn random_bytes_decodes_hex() {
273        test_host::reset();
274        test_host::with_mock(|m| {
275            m.crypto_random_bytes_response = Some("deadbeef".into());
276        });
277        let bytes = random_bytes(4).unwrap();
278        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
279        assert_eq!(
280            test_host::read_mock(|m| m.last_random_bytes_length),
281            Some(4)
282        );
283    }
284
285    #[test]
286    fn random_bytes_no_data_is_error() {
287        test_host::reset();
288        match random_bytes(8).unwrap_err() {
289            RandomBytesError::NoData => {}
290            other => panic!("expected NoData, got {:?}", other),
291        }
292    }
293
294    #[test]
295    fn random_bytes_bad_hex_is_error() {
296        test_host::reset();
297        test_host::with_mock(|m| {
298            m.crypto_random_bytes_response = Some("xxxx".into());
299        });
300        match random_bytes(2).unwrap_err() {
301            RandomBytesError::BadHex => {}
302            other => panic!("expected BadHex, got {:?}", other),
303        }
304    }
305}