mythic-c2 0.2.1

Mythic C2 agent library — message encoding, AES-256-CBC-HMAC crypto, and transport abstraction for the full agent lifecycle
Documentation
//! Wire codec — AES-256-CBC-HMAC crypto, serialize, frame, and base64-encode/decode.
//!
//! ## Pipelines
//!
//! Encode:  `Message ──[JSON]──→ bytes ──[AES]──→ body ──[UUID+body]──→ base64`
//! Decode:  `base64 ──→ UUID+body ──[AES⁻¹]──→ bytes ──[JSON⁻¹]──→ Message`
//!
//! The AES layer is optional — omitted when no crypto is provided.
//!
//! Cipher details: IV (16 bytes) + ciphertext + HMAC-SHA256 (32 bytes), PKCS7 padding.
//! The caller provides a fresh random IV per message via [`C2Transport::random_iv`].

use aes::Aes256;
use alloc::{
    string::{String, ToString},
    vec::Vec,
};
use base64::{
    Engine as _,
    engine::general_purpose::{STANDARD, URL_SAFE},
};
use cbc::{Decryptor, Encryptor};
use cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding::Pkcs7};
use hmac::{Hmac, Mac};
use serde::{Serialize, de::DeserializeOwned};
use sha2::Sha256;
use uuid::Uuid;

use crate::MythicError;

// ── Crypto trait ───────────────────────────────────────

pub trait MythicCrypto {
    fn encrypt(&self, plaintext: &[u8], iv: &[u8; AES256_IV_LEN]) -> Result<Vec<u8>, MythicError>;
    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, MythicError>;
}

// ── Constants ───────────────────────────────────────────

pub const AES256_KEY_LEN: usize = 32;
pub const AES256_IV_LEN: usize = 16;
pub const AES256_HMAC_LEN: usize = 32;
pub const MYTHIC_UUID_LEN: usize = 36;

// ── Aes256HmacCrypto ────────────────────────────────────

type Aes256CbcEncryptor = Encryptor<Aes256>;
type Aes256CbcDecryptor = Decryptor<Aes256>;
type HmacSha256 = Hmac<Sha256>;

pub struct Aes256HmacCrypto {
    key: [u8; AES256_KEY_LEN],
}

impl core::fmt::Debug for Aes256HmacCrypto {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("Aes256HmacCrypto")
            .field("key", &"<redacted>")
            .finish()
    }
}

impl Aes256HmacCrypto {
    pub fn new(key: [u8; AES256_KEY_LEN]) -> Self {
        Self { key }
    }

    pub fn from_base64_key(key_b64: &str) -> Result<Self, MythicError> {
        let key = STANDARD
            .decode(key_b64.trim().as_bytes())
            .map_err(|_| MythicError::Crypto)?;
        if key.len() != AES256_KEY_LEN {
            return Err(MythicError::Crypto);
        }
        let mut key_bytes = [0u8; AES256_KEY_LEN];
        key_bytes.copy_from_slice(&key);
        Ok(Self::new(key_bytes))
    }

    pub fn key_b64(&self) -> String {
        STANDARD.encode(self.key)
    }
}

impl MythicCrypto for Aes256HmacCrypto {
    fn encrypt(&self, plaintext: &[u8], iv: &[u8; AES256_IV_LEN]) -> Result<Vec<u8>, MythicError> {
        let ciphertext = Aes256CbcEncryptor::new_from_slices(&self.key, iv)
            .map_err(|_| MythicError::Crypto)?
            .encrypt_padded_vec_mut::<Pkcs7>(plaintext);

        let mut mac = HmacSha256::new_from_slice(&self.key).map_err(|_| MythicError::Crypto)?;
        mac.update(iv);
        mac.update(&ciphertext);
        let tag = mac.finalize().into_bytes();

        let mut packet = Vec::with_capacity(AES256_IV_LEN + ciphertext.len() + AES256_HMAC_LEN);
        packet.extend_from_slice(iv);
        packet.extend_from_slice(&ciphertext);
        packet.extend_from_slice(&tag);
        Ok(packet)
    }

    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, MythicError> {
        if ciphertext.len() < AES256_IV_LEN + AES256_HMAC_LEN {
            return Err(MythicError::Crypto);
        }

        let (iv, rest) = ciphertext.split_at(AES256_IV_LEN);
        let (ct, tag) = rest.split_at(rest.len() - AES256_HMAC_LEN);

        let mut mac = HmacSha256::new_from_slice(&self.key).map_err(|_| MythicError::Crypto)?;
        mac.update(iv);
        mac.update(ct);
        mac.verify_slice(tag).map_err(|_| MythicError::Crypto)?;

        Aes256CbcDecryptor::new_from_slices(&self.key, iv)
            .map_err(|_| MythicError::Crypto)?
            .decrypt_padded_vec_mut::<Pkcs7>(ct)
            .map_err(|_| MythicError::Crypto)
    }
}

// ── Base64 helpers ──────────────────────────────────────

fn base64_decode(packed: &str) -> Result<Vec<u8>, MythicError> {
    let bytes = packed.trim().as_bytes();
    URL_SAFE
        .decode(bytes)
        .or_else(|_| STANDARD.decode(bytes))
        .map_err(|_| MythicError::Base64)
}

fn base64_encode(data: &[u8]) -> String {
    URL_SAFE.encode(data)
}

// ── Frame / unframe ────────────────────────────────────

fn frame(uuid: Uuid, body: &[u8]) -> Vec<u8> {
    let header = uuid.hyphenated().to_string();
    let mut packet = Vec::with_capacity(MYTHIC_UUID_LEN + body.len());
    packet.extend_from_slice(header.as_bytes());
    packet.extend_from_slice(body);
    packet
}

fn unframe(packet: &[u8], expected_uuid: Option<Uuid>) -> Result<(Uuid, &[u8]), MythicError> {
    if packet.len() < MYTHIC_UUID_LEN {
        return Err(MythicError::InvalidPacket);
    }
    let (uuid_bytes, body) = packet.split_at(MYTHIC_UUID_LEN);
    let uuid_str = core::str::from_utf8(uuid_bytes).map_err(|_| MythicError::Utf8)?;
    let uuid = Uuid::parse_str(uuid_str).map_err(|_| MythicError::InvalidUuid)?;

    if expected_uuid.is_some_and(|expected| expected != uuid) {
        return Err(MythicError::UuidMismatch);
    }
    Ok((uuid, body))
}

// ── Public encode / decode ──────────────────────────────

/// Serialize, encrypt, frame, and base64-encode a message.
pub fn encode_message<T: Serialize>(
    msg: &T,
    uuid: Uuid,
    crypto: &impl MythicCrypto,
    iv: &[u8; AES256_IV_LEN],
) -> Result<String, MythicError> {
    let json = serde_json::to_vec(msg).map_err(|_| MythicError::Serialize)?;
    let body = crypto.encrypt(&json, iv)?;
    Ok(base64_encode(&frame(uuid, &body)))
}

/// Serialize, frame, and base64-encode a message **without encryption**.
pub fn encode_message_plain<T: Serialize>(msg: &T, uuid: Uuid) -> Result<String, MythicError> {
    let json = serde_json::to_vec(msg).map_err(|_| MythicError::Serialize)?;
    Ok(base64_encode(&frame(uuid, &json)))
}

/// Base64-decode, unframe, decrypt, and deserialize a wire message.
pub fn decode_message<T: DeserializeOwned>(
    packed: &str,
    expected_uuid: Option<Uuid>,
    crypto: &impl MythicCrypto,
) -> Result<(Uuid, T), MythicError> {
    let packet = base64_decode(packed)?;
    let (uuid, body) = unframe(&packet, expected_uuid)?;
    let json = crypto.decrypt(body)?;
    let msg = serde_json::from_slice(&json).map_err(|_| MythicError::Deserialize)?;
    Ok((uuid, msg))
}

/// Base64-decode, unframe, and deserialize a wire message **without decryption**.
pub fn decode_message_plain<T: DeserializeOwned>(
    packed: &str,
    expected_uuid: Option<Uuid>,
) -> Result<(Uuid, T), MythicError> {
    let packet = base64_decode(packed)?;
    let (uuid, body) = unframe(&packet, expected_uuid)?;
    let msg = serde_json::from_slice(body).map_err(|_| MythicError::Deserialize)?;
    Ok((uuid, msg))
}

// ── Tests ───────────────────────────────────────────────

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

    use super::super::checkin::ReqCheckin;

    // ── AES crypto tests ─────────────────────────────

    #[test]
    fn aes_roundtrip() {
        let crypto = Aes256HmacCrypto::new([0x11; AES256_KEY_LEN]);
        let iv = [0x22; AES256_IV_LEN];
        let msg = b"hello mythic aes".to_vec();
        let enc = crypto.encrypt(&msg, &iv).unwrap();
        assert_eq!(crypto.decrypt(&enc).unwrap(), msg);
    }

    #[test]
    fn aes_rejects_tampering() {
        let crypto = Aes256HmacCrypto::new([0x11; AES256_KEY_LEN]);
        let iv = [0x22; AES256_IV_LEN];
        let mut enc = crypto.encrypt(b"hello", &iv).unwrap();
        enc[0] ^= 0x01;
        assert!(matches!(crypto.decrypt(&enc), Err(MythicError::Crypto)));
    }

    #[test]
    fn known_mythic_test_vector() {
        // From the Mythic docs initial-checkin.md
        let key_b64 = "hfN9Nk29S8LsjrE9ffbT9KONue4uozk+/TVMyrxDvvM=";
        let payload_b64 = "ODA4NDRkMTktOWJmYy00N2Y5LWI5YWYtYzZiOTE0NGMwZmRjnZ/FcM9jnfvzAv/RYFPAvkGH8+nWHAGqxcBXSlPvq8jbCRoZrVvSSZOxNwg15q3Etz9hEb7Qunv1Sm3/8SSzp+ne4fxFObunQWzHo+7tS68csvn/uxqhiyvD83KK66xtPyGzPFlK1ZXD+wxDbo2M3iSYPEp0m5w+rQhzm5aTA6Gk6p0KSXovYvnY3TsJtdgVPlY1cFt75UzTd0iIFU8hJ+KbhyMUjJujLA6++sVrXuFps2TbAi21Z5Hr/g3/S6HAk/RSedKyXEZ6Hbbgx3gESsHa/QuVjP9Lz+Y6H9I4DtgEunCHddvruJUPqYxFGT2m8WbGc6AH6+m2ucexym0yBUryuFWfsrW6QSfcGUaVb4DWrVHtqHcXctYRNb7pOf0T/P26pFt77fgii4j0RgzTGod9QDWhSfvte+ffUWjsWKyixUffjIffj45sgDS0tvtT2Rej8gFiIpAs9F/oOH/ps5pRQeflULd1eH0GKh5WUcDwsjUa89KeOcts44J+E5+7trQ3q2q9Uy8S96DM8Nr5QryokeCD7J0goKZQPdutVXzwIvI9RT7zCQpV8CrRTpQ63L9P9IhIpyT+TDvorQd0v/I/DGb6Ev/ZUAxbyAR0JLJGjYYv1NUno5Ru2Plv1wsn82YanVF1V2LE1ii6DC7jclrkgfKN9Qhli+hIiUwSJ3YvFTT1ybHf/Fyw4ZZ6PiOIZIWgcJmHUHx//1TNvlTrmABitRpwb75yuJ6ZfYnKv/BlrQtJ9nFveNeYKP/rL7uYwPq3RY9IJRK7DBOqy53qiiysRfhimraW//sXc6duBmASW0ijZ21HKaqdVr72PMIJpEWghIznzpzEVpJqYj0uR9K/bL5W6kfIP43dyDBzGAGd87VBIcUTsIJLWaOHGPVmO3OmmtIfW34ivsX1TElTVjyrmKneQ+OTWww0RbXZdE5swvucXqC8wTuwybgwQWVPCvrBTBlv3iXgkP4dOjbvr1YZS+HpdbT5OEhwIqnDCXIqItVYx9Hz5BdfcBFbXUXk0SIQzWQj9xw+olYYQMrxomNvjuGxBkOmhTJf6yUyRK1Mp8b992FPBzLVRexYFc5FZxrI8CJeS91R3C21gb3SZH4EdKk1S3mR40O427TGYG5Hcqzqz5n0M6+cWORxUp7LKT34kDwgzHQK1h5kEoaGvGB1QDtx8GLsbfk/BqBoV2oHGJP1HHbVgYMgBTrkYObXOKFW8WyaUWcB1p/dSmW5Ww==";

        let crypto = Aes256HmacCrypto::from_base64_key(key_b64).unwrap();
        let packet = STANDARD.decode(payload_b64.as_bytes()).unwrap();

        let uuid_str = core::str::from_utf8(&packet[..36]).unwrap();
        assert_eq!(uuid_str, "80844d19-9bfc-47f9-b9af-c6b9144c0fdc");

        let plaintext = crypto.decrypt(&packet[36..]).unwrap();
        let json = core::str::from_utf8(&plaintext).unwrap();
        assert!(json.contains("\"action\":\"checkin\""));
        assert!(json.contains("\"user\":\"itsafeature\""));
        assert!(json.contains("\"host\":\"spooky.local\""));
        assert!(json.contains("\"pid\":7437"));
    }

    #[test]
    fn encrypt_matches_known_mythic_ciphertext() {
        let key_b64 = "hfN9Nk29S8LsjrE9ffbT9KONue4uozk+/TVMyrxDvvM=";
        let payload_b64 = "ODA4NDRkMTktOWJmYy00N2Y5LWI5YWYtYzZiOTE0NGMwZmRjnZ/FcM9jnfvzAv/RYFPAvkGH8+nWHAGqxcBXSlPvq8jbCRoZrVvSSZOxNwg15q3Etz9hEb7Qunv1Sm3/8SSzp+ne4fxFObunQWzHo+7tS68csvn/uxqhiyvD83KK66xtPyGzPFlK1ZXD+wxDbo2M3iSYPEp0m5w+rQhzm5aTA6Gk6p0KSXovYvnY3TsJtdgVPlY1cFt75UzTd0iIFU8hJ+KbhyMUjJujLA6++sVrXuFps2TbAi21Z5Hr/g3/S6HAk/RSedKyXEZ6Hbbgx3gESsHa/QuVjP9Lz+Y6H9I4DtgEunCHddvruJUPqYxFGT2m8WbGc6AH6+m2ucexym0yBUryuFWfsrW6QSfcGUaVb4DWrVHtqHcXctYRNb7pOf0T/P26pFt77fgii4j0RgzTGod9QDWhSfvte+ffUWjsWKyixUffjIffj45sgDS0tvtT2Rej8gFiIpAs9F/oOH/ps5pRQeflULd1eH0GKh5WUcDwsjUa89KeOcts44J+E5+7trQ3q2q9Uy8S96DM8Nr5QryokeCD7J0goKZQPdutVXzwIvI9RT7zCQpV8CrRTpQ63L9P9IhIpyT+TDvorQd0v/I/DGb6Ev/ZUAxbyAR0JLJGjYYv1NUno5Ru2Plv1wsn82YanVF1V2LE1ii6DC7jclrkgfKN9Qhli+hIiUwSJ3YvFTT1ybHf/Fyw4ZZ6PiOIZIWgcJmHUHx//1TNvlTrmABitRpwb75yuJ6ZfYnKv/BlrQtJ9nFveNeYKP/rL7uYwPq3RY9IJRK7DBOqy53qiiysRfhimraW//sXc6duBmASW0ijZ21HKaqdVr72PMIJpEWghIznzpzEVpJqYj0uR9K/bL5W6kfIP43dyDBzGAGd87VBIcUTsIJLWaOHGPVmO3OmmtIfW34ivsX1TElTVjyrmKneQ+OTWww0RbXZdE5swvucXqC8wTuwybgwQWVPCvrBTBlv3iXgkP4dOjbvr1YZS+HpdbT5OEhwIqnDCXIqItVYx9Hz5BdfcBFbXUXk0SIQzWQj9xw+olYYQMrxomNvjuGxBkOmhTJf6yUyRK1Mp8b992FPBzLVRexYFc5FZxrI8CJeS91R3C21gb3SZH4EdKk1S3mR40O427TGYG5Hcqzqz5n0M6+cWORxUp7LKT34kDwgzHQK1h5kEoaGvGB1QDtx8GLsbfk/BqBoV2oHGJP1HHbVgYMgBTrkYObXOKFW8WyaUWcB1p/dSmW5Ww==";

        let crypto = Aes256HmacCrypto::from_base64_key(key_b64).unwrap();
        let packet = STANDARD.decode(payload_b64.as_bytes()).unwrap();

        let iv: [u8; 16] = packet[36..52].try_into().unwrap();
        let known_blob = &packet[36..];
        let plaintext = crypto.decrypt(known_blob).unwrap();
        let our_blob = crypto.encrypt(&plaintext, &iv).unwrap();
        assert_eq!(our_blob, known_blob);
    }

    // ── Full pipeline: JSON → encode → decode → JSON ──

    #[test]
    fn known_test_vector_full_pipeline() {
        // Mythic docs initial-checkin.md known-good values
        let key_b64 = "hfN9Nk29S8LsjrE9ffbT9KONue4uozk+/TVMyrxDvvM=";
        let payload_b64 = "ODA4NDRkMTktOWJmYy00N2Y5LWI5YWYtYzZiOTE0NGMwZmRjnZ/FcM9jnfvzAv/RYFPAvkGH8+nWHAGqxcBXSlPvq8jbCRoZrVvSSZOxNwg15q3Etz9hEb7Qunv1Sm3/8SSzp+ne4fxFObunQWzHo+7tS68csvn/uxqhiyvD83KK66xtPyGzPFlK1ZXD+wxDbo2M3iSYPEp0m5w+rQhzm5aTA6Gk6p0KSXovYvnY3TsJtdgVPlY1cFt75UzTd0iIFU8hJ+KbhyMUjJujLA6++sVrXuFps2TbAi21Z5Hr/g3/S6HAk/RSedKyXEZ6Hbbgx3gESsHa/QuVjP9Lz+Y6H9I4DtgEunCHddvruJUPqYxFGT2m8WbGc6AH6+m2ucexym0yBUryuFWfsrW6QSfcGUaVb4DWrVHtqHcXctYRNb7pOf0T/P26pFt77fgii4j0RgzTGod9QDWhSfvte+ffUWjsWKyixUffjIffj45sgDS0tvtT2Rej8gFiIpAs9F/oOH/ps5pRQeflULd1eH0GKh5WUcDwsjUa89KeOcts44J+E5+7trQ3q2q9Uy8S96DM8Nr5QryokeCD7J0goKZQPdutVXzwIvI9RT7zCQpV8CrRTpQ63L9P9IhIpyT+TDvorQd0v/I/DGb6Ev/ZUAxbyAR0JLJGjYYv1NUno5Ru2Plv1wsn82YanVF1V2LE1ii6DC7jclrkgfKN9Qhli+hIiUwSJ3YvFTT1ybHf/Fyw4ZZ6PiOIZIWgcJmHUHx//1TNvlTrmABitRpwb75yuJ6ZfYnKv/BlrQtJ9nFveNeYKP/rL7uYwPq3RY9IJRK7DBOqy53qiiysRfhimraW//sXc6duBmASW0ijZ21HKaqdVr72PMIJpEWghIznzpzEVpJqYj0uR9K/bL5W6kfIP43dyDBzGAGd87VBIcUTsIJLWaOHGPVmO3OmmtIfW34ivsX1TElTVjyrmKneQ+OTWww0RbXZdE5swvucXqC8wTuwybgwQWVPCvrBTBlv3iXgkP4dOjbvr1YZS+HpdbT5OEhwIqnDCXIqItVYx9Hz5BdfcBFbXUXk0SIQzWQj9xw+olYYQMrxomNvjuGxBkOmhTJf6yUyRK1Mp8b992FPBzLVRexYFc5FZxrI8CJeS91R3C21gb3SZH4EdKk1S3mR40O427TGYG5Hcqzqz5n0M6+cWORxUp7LKT34kDwgzHQK1h5kEoaGvGB1QDtx8GLsbfk/BqBoV2oHGJP1HHbVgYMgBTrkYObXOKFW8WyaUWcB1p/dSmW5Ww==";

        let crypto = Aes256HmacCrypto::from_base64_key(key_b64).unwrap();
        // Step 1: decode the known packet
        let uuid = Uuid::parse_str("80844d19-9bfc-47f9-b9af-c6b9144c0fdc").unwrap();
        let (decoded_uuid, decoded_req): (Uuid, ReqCheckin) =
            decode_message(payload_b64, Some(uuid), &crypto).unwrap();
        assert_eq!(decoded_uuid, uuid);
        assert_eq!(decoded_req.action, "checkin");
        assert_eq!(decoded_req.user.as_deref(), Some("itsafeature"));
        assert_eq!(decoded_req.host.as_deref(), Some("spooky.local"));
        assert_eq!(decoded_req.pid, Some(7437));
        assert_eq!(
            decoded_req.os.as_deref(),
            Some("Version 13.4 (Build 22F66)")
        );

        // Step 2: re-encode with a fresh IV, then decode — roundtrip
        let fresh_iv = [0xAA; 16];
        let re_encoded = encode_message(&decoded_req, uuid, &crypto, &fresh_iv).unwrap();
        let (uuid2, decoded2): (Uuid, ReqCheckin) =
            decode_message(&re_encoded, Some(uuid), &crypto).unwrap();
        assert_eq!(uuid2, uuid);
        assert_eq!(decoded2, decoded_req);

        // Step 3: decode without expected UUID — still works
        let (uuid3, decoded3): (Uuid, ReqCheckin) =
            decode_message(&re_encoded, None, &crypto).unwrap();
        assert_eq!(uuid3, uuid);
        assert_eq!(decoded3, decoded_req);

        // Step 4: plaintext roundtrip (no encryption)
        let plain_encoded = encode_message_plain(&decoded_req, uuid).unwrap();
        let (uuid4, decoded4): (Uuid, ReqCheckin) =
            decode_message_plain(&plain_encoded, Some(uuid)).unwrap();
        assert_eq!(uuid4, uuid);
        assert_eq!(decoded4, decoded_req);
    }

    #[test]
    fn different_ivs_produce_different_output() {
        let crypto = Aes256HmacCrypto::new([0x11; AES256_KEY_LEN]);
        let msg = b"hello";
        let e1 = crypto.encrypt(msg, &[0xAA; 16]).unwrap();
        let e2 = crypto.encrypt(msg, &[0xBB; 16]).unwrap();
        assert_ne!(e1, e2);
        assert_eq!(crypto.decrypt(&e1).unwrap(), msg);
        assert_eq!(crypto.decrypt(&e2).unwrap(), msg);
    }
}