Skip to main content

seq_runtime/crypto/
aes.rs

1//! AES-256-GCM authenticated encryption.
2
3use crate::seqstring::{global_bytes, global_string};
4use crate::stack::{Stack, pop, push};
5use crate::value::Value;
6
7use aes_gcm::{
8    Aes256Gcm, Nonce,
9    aead::{Aead, KeyInit as AesKeyInit, OsRng, rand_core::RngCore as AeadRngCore},
10};
11use base64::{Engine, engine::general_purpose::STANDARD};
12
13use super::{AES_GCM_TAG_SIZE, AES_KEY_SIZE, AES_NONCE_SIZE};
14
15/// Encrypt plaintext using AES-256-GCM
16///
17/// Stack effect: ( String String -- String Bool )
18///
19/// Arguments:
20/// - plaintext: The string to encrypt
21/// - key: Hex-encoded 32-byte key (64 hex characters)
22///
23/// Returns:
24/// - ciphertext: base64(nonce || ciphertext || tag)
25/// - success: Bool indicating success
26///
27/// # Safety
28/// Stack must have two String values on top
29#[unsafe(no_mangle)]
30pub unsafe extern "C" fn patch_seq_crypto_aes_gcm_encrypt(stack: Stack) -> Stack {
31    assert!(!stack.is_null(), "crypto.aes-gcm-encrypt: stack is null");
32
33    let (stack, key_val) = unsafe { pop(stack) };
34    let (stack, plaintext_val) = unsafe { pop(stack) };
35
36    match (plaintext_val, key_val) {
37        (Value::String(plaintext), Value::String(key_hex)) => {
38            // Plaintext is byte-clean — random bytes, file content,
39            // pre-encoded structured data all encrypt correctly.
40            // Key is text (hex) so we still go through `as_str_or_empty`.
41            match aes_gcm_encrypt(plaintext.as_bytes(), key_hex.as_str_or_empty()) {
42                Some(ciphertext) => {
43                    let stack = unsafe { push(stack, Value::String(global_string(ciphertext))) };
44                    unsafe { push(stack, Value::Bool(true)) }
45                }
46                None => {
47                    let stack = unsafe { push(stack, Value::String(global_string(String::new()))) };
48                    unsafe { push(stack, Value::Bool(false)) }
49                }
50            }
51        }
52        _ => panic!("crypto.aes-gcm-encrypt: expected two Strings on stack"),
53    }
54}
55
56/// Decrypt ciphertext using AES-256-GCM
57///
58/// Stack effect: ( String String -- String Bool )
59///
60/// Arguments:
61/// - ciphertext: base64(nonce || ciphertext || tag)
62/// - key: Hex-encoded 32-byte key (64 hex characters)
63///
64/// Returns:
65/// - plaintext: The decrypted string
66/// - success: Bool indicating success
67///
68/// # Safety
69/// Stack must have two String values on top
70#[unsafe(no_mangle)]
71pub unsafe extern "C" fn patch_seq_crypto_aes_gcm_decrypt(stack: Stack) -> Stack {
72    assert!(!stack.is_null(), "crypto.aes-gcm-decrypt: stack is null");
73
74    let (stack, key_val) = unsafe { pop(stack) };
75    let (stack, ciphertext_val) = unsafe { pop(stack) };
76
77    match (ciphertext_val, key_val) {
78        (Value::String(ciphertext), Value::String(key_hex)) => {
79            // Ciphertext is base64 (text). Plaintext bytes come back
80            // raw — wrap them in a byte-clean SeqString so binary
81            // payloads survive the round-trip.
82            match aes_gcm_decrypt(ciphertext.as_str_or_empty(), key_hex.as_str_or_empty()) {
83                Some(plaintext_bytes) => {
84                    let stack =
85                        unsafe { push(stack, Value::String(global_bytes(plaintext_bytes))) };
86                    unsafe { push(stack, Value::Bool(true)) }
87                }
88                None => {
89                    let stack = unsafe { push(stack, Value::String(global_string(String::new()))) };
90                    unsafe { push(stack, Value::Bool(false)) }
91                }
92            }
93        }
94        _ => panic!("crypto.aes-gcm-decrypt: expected two Strings on stack"),
95    }
96}
97
98/// Encrypt arbitrary bytes with AES-256-GCM. The plaintext is treated
99/// as bytes (binary or text), the key is hex-encoded, and the
100/// returned ciphertext is base64-encoded (so always valid UTF-8 and
101/// safe to store in any string-typed field).
102pub(super) fn aes_gcm_encrypt(plaintext: &[u8], key_hex: &str) -> Option<String> {
103    // Decode hex key
104    let key_bytes = hex::decode(key_hex).ok()?;
105    if key_bytes.len() != AES_KEY_SIZE {
106        return None;
107    }
108
109    // Create cipher
110    let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
111
112    // Generate random nonce
113    let mut nonce_bytes = [0u8; AES_NONCE_SIZE];
114    OsRng.fill_bytes(&mut nonce_bytes);
115    let nonce = Nonce::from_slice(&nonce_bytes);
116
117    // Encrypt
118    let ciphertext = cipher.encrypt(nonce, plaintext).ok()?;
119
120    // Combine: nonce || ciphertext (tag is appended by aes-gcm)
121    let mut combined = Vec::with_capacity(AES_NONCE_SIZE + ciphertext.len());
122    combined.extend_from_slice(&nonce_bytes);
123    combined.extend_from_slice(&ciphertext);
124
125    Some(STANDARD.encode(&combined))
126}
127
128/// Decrypt an AES-256-GCM ciphertext (base64-encoded) with the given
129/// hex-encoded key. Returns the recovered plaintext as raw bytes —
130/// callers can wrap them in a byte-clean SeqString (binary plaintext
131/// preserved) or validate as UTF-8 if they expect text.
132pub(super) fn aes_gcm_decrypt(ciphertext_b64: &str, key_hex: &str) -> Option<Vec<u8>> {
133    // Decode base64
134    let combined = STANDARD.decode(ciphertext_b64).ok()?;
135    if combined.len() < AES_NONCE_SIZE + AES_GCM_TAG_SIZE {
136        // At minimum: nonce + tag
137        return None;
138    }
139
140    // Decode hex key
141    let key_bytes = hex::decode(key_hex).ok()?;
142    if key_bytes.len() != AES_KEY_SIZE {
143        return None;
144    }
145
146    // Split nonce and ciphertext
147    let (nonce_bytes, ciphertext) = combined.split_at(AES_NONCE_SIZE);
148    let nonce = Nonce::from_slice(nonce_bytes);
149
150    // Create cipher and decrypt
151    let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
152    cipher.decrypt(nonce, ciphertext).ok()
153}