- 1
//! Defense-in-depth zero-trust cryptography for the distributed message fabric. - 2
//! - 3
//! Provides authenticated AES-256-GCM envelope payload encryption, HKDF-SHA256 - 4
//! key derivation, and AAD (Additional Authenticated Data) metadata binding. - 5
- 6
use ring::aead::{ - 7
AES_256_GCM, Aad, BoundKey, NONCE_LEN, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey, - 8
}; - 9
use ring::hkdf::{HKDF_SHA256, Salt}; - 10
use ring::rand::{SecureRandom, SystemRandom}; - 11
use sha2::{Digest, Sha256}; - 12
use thiserror::Error; - 13
- 14
use crate::envelope::{EncryptionMeta, MessageEnvelope}; - 15
- 16
#[derive(Debug, Error)] - 17
pub enum CryptoError { - 18
#[error("envelope is already encrypted")] - 19
AlreadyEncrypted, - 20
#[error("envelope is not encrypted")] - 21
NotEncrypted, - 22
#[error("missing encryption metadata in envelope")] - 23
MissingMetadata, - 24
#[error("unsupported encryption algorithm: {0}")] - 25
UnsupportedAlgorithm(String), - 26
#[error("invalid nonce format: {0}")] - 27
InvalidNonce(String), - 28
#[error("random generation error")] - 29
RandomError, - 30
#[error("key derivation error")] - 31
KeyDerivationError, - 32
#[error("encryption operation failed")] - 33
EncryptionFailed, - 34
#[error("decryption failed or authentication tag mismatch (tampering detected)")] - 35
DecryptionFailed, - 36
} - 37
- 38
struct SingleNonce(Option<[u8; NONCE_LEN]>); - 39
- 40
impl SingleNonce { - 41
fn new(nonce: [u8; NONCE_LEN]) -> Self { - 42
Self(Some(nonce)) - 43
} - 44
} - 45
- 46
impl NonceSequence for SingleNonce { - 47
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> { - 48
self.0 - 49
.take() - 50
.map(Nonce::assume_unique_for_key) - 51
.ok_or(ring::error::Unspecified) - 52
} - 53
} - 54
- 55
/// Derive a 32-byte AES-256-GCM symmetric key from a workspace secret using HKDF-SHA256. - 56
pub fn derive_workspace_key( - 57
workspace_secret: &[u8], - 58
key_id: &str, - 59
) -> Result<[u8; 32], CryptoError> { - 60
struct KeyMaterial; - 61
impl ring::hkdf::KeyType for KeyMaterial { - 62
fn len(&self) -> usize { - 63
32 - 64
} - 65
} - 66
- 67
let salt = Salt::new(HKDF_SHA256, b"vak-bus-hkdf-salt-v1"); - 68
let prk = salt.extract(workspace_secret); - 69
let info = [b"vak-bus-aes-256-gcm-", key_id.as_bytes()]; - 70
- 71
let okm = prk - 72
.expand(&info, KeyMaterial) - 73
.map_err(|_| CryptoError::KeyDerivationError)?; - 74
- 75
let mut key_bytes = [0u8; 32]; - 76
okm.fill(&mut key_bytes) - 77
.map_err(|_| CryptoError::KeyDerivationError)?; - 78
- 79
Ok(key_bytes) - 80
} - 81
- 82
/// Encrypt an envelope's payload in-place using AES-256-GCM. - 83
/// - 84
/// Binds envelope metadata as AAD so headers (id, type, workspace, seq) cannot - 85
/// be manipulated or swapped without failing authentication. - 86
pub fn encrypt_envelope( - 87
envelope: &mut MessageEnvelope, - 88
workspace_secret: &[u8], - 89
key_id: &str, - 90
) -> Result<(), CryptoError> { - 91
if envelope.encrypted { - 92
return Err(CryptoError::AlreadyEncrypted); - 93
} - 94
- 95
let key_bytes = derive_workspace_key(workspace_secret, key_id)?; - 96
let unbound_key = - 97
UnboundKey::new(&AES_256_GCM, &key_bytes).map_err(|_| CryptoError::EncryptionFailed)?; - 98
- 99
// Generate 12-byte random nonce - 100
let rng = SystemRandom::new(); - 101
let mut nonce_bytes = [0u8; NONCE_LEN]; - 102
rng.fill(&mut nonce_bytes) - 103
.map_err(|_| CryptoError::RandomError)?; - 104
- 105
let mut sealing_key = SealingKey::new(unbound_key, SingleNonce::new(nonce_bytes)); - 106
- 107
let aad_bytes = envelope.aad_bytes(); - 108
let aad = Aad::from(&aad_bytes); - 109
- 110
// Encrypt payload in-place - 111
sealing_key - 112
.seal_in_place_append_tag(aad, &mut envelope.payload) - 113
.map_err(|_| CryptoError::EncryptionFailed)?; - 114
- 115
// Update envelope state - 116
envelope.encrypted = true; - 117
envelope.datacontenttype = "application/octet-stream".to_string(); - 118
envelope.encryption = Some(EncryptionMeta { - 119
algorithm: "AES-256-GCM".to_string(), - 120
key_id: key_id.to_string(), - 121
nonce_hex: hex::encode(nonce_bytes), - 122
}); - 123
- 124
let mut hasher = Sha256::new(); - 125
hasher.update(&envelope.payload); - 126
envelope.lineage.payload_hash = hex::encode(hasher.finalize()); - 127
- 128
Ok(()) - 129
} - 130
- 131
/// Decrypt an envelope's payload in-place using AES-256-GCM. - 132
/// - 133
/// Verifies the AAD and tag; returns `CryptoError::DecryptionFailed` if any bit - 134
/// of the ciphertext, metadata, or nonce was tampered with. - 135
pub fn decrypt_envelope( - 136
envelope: &mut MessageEnvelope, - 137
workspace_secret: &[u8], - 138
) -> Result<(), CryptoError> { - 139
if !envelope.encrypted { - 140
return Err(CryptoError::NotEncrypted); - 141
} - 142
- 143
let meta = envelope - 144
.encryption - 145
.as_ref() - 146
.ok_or(CryptoError::MissingMetadata)?; - 147
- 148
if meta.algorithm != "AES-256-GCM" { - 149
return Err(CryptoError::UnsupportedAlgorithm(meta.algorithm.clone())); - 150
} - 151
- 152
let nonce_raw = - 153
hex::decode(&meta.nonce_hex).map_err(|e| CryptoError::InvalidNonce(e.to_string()))?; - 154
if nonce_raw.len() != NONCE_LEN { - 155
return Err(CryptoError::InvalidNonce(format!( - 156
"expected {NONCE_LEN} bytes, got {}", - 157
nonce_raw.len() - 158
))); - 159
} - 160
let mut nonce_bytes = [0u8; NONCE_LEN]; - 161
nonce_bytes.copy_from_slice(&nonce_raw); - 162
- 163
let key_bytes = derive_workspace_key(workspace_secret, &meta.key_id)?; - 164
let unbound_key = - 165
UnboundKey::new(&AES_256_GCM, &key_bytes).map_err(|_| CryptoError::DecryptionFailed)?; - 166
- 167
let mut opening_key = OpeningKey::new(unbound_key, SingleNonce::new(nonce_bytes)); - 168
- 169
let aad_bytes = envelope.aad_bytes(); - 170
let aad = Aad::from(&aad_bytes); - 171
- 172
let decrypted_slice = opening_key - 173
.open_in_place(aad, &mut envelope.payload) - 174
.map_err(|_| CryptoError::DecryptionFailed)?; - 175
- 176
let decrypted_len = decrypted_slice.len(); - 177
envelope.payload.truncate(decrypted_len); - 178
- 179
envelope.encrypted = false; - 180
envelope.datacontenttype = "application/json".to_string(); - 181
envelope.encryption = None; - 182
- 183
let mut hasher = Sha256::new(); - 184
hasher.update(&envelope.payload); - 185
envelope.lineage.payload_hash = hex::encode(hasher.finalize()); - 186
- 187
Ok(()) - 188
} - 189
- 190
#[cfg(test)] - 191
mod tests { - 192
use super::*; - 193
use crate::envelope::MessageEnvelope; - 194
- 195
#[test] - 196
fn round_trip_encryption_decryption() { - 197
let secret = b"super-secret-workspace-key-2026"; - 198
let original_data = b"{\"command\":\"cargo build\",\"exit_code\":0}"; - 199
- 200
let mut env = MessageEnvelope::new( - 201
"vak://test", - 202
"vak.work.task", - 203
"ws_123", - 204
"agent_456", - 205
1, - 206
MessageEnvelope::GENESIS_HASH, - 207
original_data.to_vec(), - 208
None, - 209
); - 210
- 211
assert!(!env.encrypted); - 212
encrypt_envelope(&mut env, secret, "key_v1").expect("encryption succeeds"); - 213
assert!(env.encrypted); - 214
assert_ne!(env.payload, original_data); - 215
- 216
decrypt_envelope(&mut env, secret).expect("decryption succeeds"); - 217
assert!(!env.encrypted); - 218
assert_eq!(env.payload, original_data); - 219
} - 220
- 221
#[test] - 222
fn tamper_detection_ciphertext() { - 223
let secret = b"super-secret-workspace-key-2026"; - 224
let mut env = MessageEnvelope::new( - 225
"vak://test", - 226
"vak.work.task", - 227
"ws_123", - 228
"agent_456", - 229
1, - 230
MessageEnvelope::GENESIS_HASH, - 231
b"secret-payload".to_vec(), - 232
None, - 233
); - 234
- 235
encrypt_envelope(&mut env, secret, "key_v1").unwrap(); - 236
// Flip one byte in the ciphertext - 237
if let Some(byte) = env.payload.first_mut() { - 238
*byte ^= 0xFF; - 239
} - 240
- 241
let err = decrypt_envelope(&mut env, secret).unwrap_err(); - 242
assert!(matches!(err, CryptoError::DecryptionFailed)); - 243
} - 244
- 245
#[test] - 246
fn tamper_detection_metadata_aad() { - 247
let secret = b"super-secret-workspace-key-2026"; - 248
let mut env = MessageEnvelope::new( - 249
"vak://test", - 250
"vak.work.task", - 251
"ws_123", - 252
"agent_456", - 253
1, - 254
MessageEnvelope::GENESIS_HASH, - 255
b"secret-payload".to_vec(), - 256
None, - 257
); - 258
- 259
encrypt_envelope(&mut env, secret, "key_v1").unwrap(); - 260
// Tamper with envelope event type (part of AAD) - 261
env.event_type = "vak.hacked.task".to_string(); - 262
- 263
let err = decrypt_envelope(&mut env, secret).unwrap_err(); - 264
assert!(matches!(err, CryptoError::DecryptionFailed)); - 265
} - 266
} - 267
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.