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