Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package.license = "Apache-2.0"

members = [
"rust/avdschema",
"rust/encrypt",
"rust/validation",
"rust/passwords",
"rust/pypasswords",
Expand Down
40 changes: 40 additions & 0 deletions pyavd_utils/passwords.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,43 @@ def simple_7_decrypt(data: str) -> str:
ValueError: If the encrypted data is invalid (too short, invalid format, invalid hex, or salt out of range).
RuntimeError: If the decrypted data is not valid UTF-8.
"""

def generate_encryption_key() -> bytes:
"""
Generate a random 32-byte AES-256 encryption key.

Returns:
bytes: A cryptographically secure random 32-byte key.
"""

def aes_encrypt(data: bytes, key: bytes) -> bytes:
"""
Encrypt data using AES-256-GCM.

Args:
data: The plaintext data to encrypt.
key: A 32-byte AES-256 encryption key.

Returns:
bytes: The encrypted data (nonce + ciphertext + auth tag).

Raises:
ValueError: If the key is not exactly 32 bytes.
RuntimeError: If encryption fails.
"""

def aes_decrypt(data: bytes, key: bytes) -> bytes:
"""
Decrypt data using AES-256-GCM.

Args:
data: The encrypted data (nonce + ciphertext + auth tag).
key: A 32-byte AES-256 encryption key.

Returns:
bytes: The decrypted plaintext data.

Raises:
ValueError: If the key is not exactly 32 bytes.
RuntimeError: If decryption fails (wrong key, corrupted data, etc.).
"""
23 changes: 16 additions & 7 deletions pyavd_utils/validation.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ class Configuration:
When validating eos_designs, emit warnings for top-level keys that exist in eos_cli_config_gen but not in eos_designs.
"""

encryption_key: bytes | None
"""
Optional 32-byte AES-256 encryption key for encrypting/decrypting data.
When set, input data is expected to be encrypted and output data will be encrypted.
"""

def __init__(
self,
*,
ignore_required_keys_on_root_dict: bool = False,
return_coercion_infos: bool = False,
restrict_null_values: bool = False,
warn_eos_cli_config_gen_keys: bool = False,
encryption_key: bytes | None = None,
) -> None: ...

class Violation:
Expand Down Expand Up @@ -72,10 +79,11 @@ class ValidationResult:
ignored_eos_config_keys: list[IgnoredEosConfigKey]

class ValidatedDataResult:
"""Result of data validation including the validated data as JSON."""
"""Result of data validation including the validated data as UTF-8 encoded JSON."""

validation_result: ValidationResult
validated_data: str | None
validated_data: bytes | None
"""The validated data as UTF-8 encoded JSON (or encrypted bytes if encryption_key was provided)."""

def init_store_from_file(file: Path) -> None:
"""
Expand All @@ -92,15 +100,15 @@ def init_store_from_file(file: Path) -> None:
"""

def validate_json(
data_as_json: str,
data: bytes,
schema_name: Literal["eos_cli_config_gen", "eos_designs"],
configuration: Configuration | None = None,
) -> ValidationResult:
"""
Validate data against a schema specified by name.

Args:
data_as_json: Structured data dumped as JSON.
data: Structured data as UTF-8 encoded JSON (or encrypted bytes if encryption_key is set in configuration).
schema_name: The name of the schema to validate against.
configuration: Optional configuration for validation behavior.

Expand All @@ -109,17 +117,18 @@ def validate_json(
"""

def get_validated_data(
data_as_json: str,
data: bytes,
schema_name: Literal["eos_cli_config_gen", "eos_designs"],
configuration: Configuration | None = None,
) -> ValidatedDataResult:
"""
Validate data against a schema specified by name and return the data after coercion and validation.

This returned data is the type-coerced data encoded as JSON, which also contains default values that got inserted during validation.
This returned data is the type-coerced data encoded as UTF-8 encoded JSON, which also contains default values that got inserted during validation.
If encryption_key is set in configuration, both input and output data are encrypted.

Args:
data_as_json: Structured data dumped as JSON.
data: Structured data as UTF-8 encoded JSON (or encrypted bytes if encryption_key is set in configuration).
schema_name: The name of the schema to validate against.
configuration: Optional configuration for validation behavior.

Expand Down
2 changes: 2 additions & 0 deletions rust/avdschema/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ serde_with = { version = "3.12.0" }
serde_yaml = { workspace = true }
walkdir = { version = "2.5.0", optional = true }
xz2 = { version = "0.1.7", optional = true }
encrypt = { path = "../encrypt", optional = true }

[features]
dump_load_files = ["dep:walkdir"]
xz2 = ["dump_load_files", "dep:xz2"]
encryption = ["dep:encrypt"]
8 changes: 5 additions & 3 deletions rust/avdschema/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ mod utils;

#[cfg(feature = "dump_load_files")]
pub use self::utils::load::LoadFromFragments;
#[cfg(feature = "encryption")]
pub use encrypt::{decrypt, encrypt, generate_key};
pub use self::{
inherit::Inherit, resolve::resolve_ref::resolve_ref, resolve::resolve_schema, schema::any,
schema::base, schema::boolean, schema::dict, schema::int, schema::list, schema::str,
store::Schema, store::Store, utils::dump::Dump,
utils::load::Load, utils::schema_from_path::SchemaKeys,
utils::schema_from_path::get_schema_from_path, utils::walker::Walker,
store::Schema, store::Store, utils::dump::Dump, utils::load::Load,
utils::schema_from_path::SchemaKeys, utils::schema_from_path::get_schema_from_path,
utils::walker::Walker,
};
12 changes: 5 additions & 7 deletions rust/avdschema/src/schema/any.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@
// Use of this source code is governed by the Apache License 2.0
// that can be found in the LICENSE file.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{
base::Deprecation,
delegate_anyschema_method,
utils::{
dump::Dump,
load::{Load, LoadError},
},
utils::{dump::Dump, load::Load},
};

#[cfg(feature = "dump_load_files")]
use crate::utils::load::LoadFromFragments;
use {
std::path::PathBuf,
crate::utils::load::{LoadError, LoadFromFragments}
};

use super::{boolean::Bool, dict::Dict, int::Int, list::List, str::Str};

Expand Down
12 changes: 7 additions & 5 deletions rust/avdschema/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@
// Use of this source code is governed by the Apache License 2.0
// that can be found in the LICENSE file.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

use crate::{
resolve_schema,
schema::any::AnySchema,
utils::{
dump::Dump,
load::{Load, LoadError},
},
utils::{dump::Dump, load::Load},
};

#[cfg(feature = "dump_load_files")]
use std::path::PathBuf;

#[cfg(feature = "dump_load_files")]
use crate::utils::load::LoadError;

/// Schema store containing the AVD schemas.
/// The store is used as entrypoint for validation and when resolving a $ref pointing to a specific schema.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down
8 changes: 8 additions & 0 deletions rust/avdschema/src/utils/dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ where
fn to_json(&self) -> Result<String, DumpError> {
Ok(serde_json::to_string(self)?)
}
#[cfg(feature = "encryption")]
fn to_encrypted_json(&self, key: &[u8; 32]) -> Result<Vec<u8>, DumpError> {
let json = self.to_json()?;
Ok(encrypt::encrypt(json.as_bytes(), key)?)
}

#[cfg(feature = "dump_load_files")]
fn to_file(&self, output: Option<&PathBuf>) -> Result<(), DumpError> {
Expand Down Expand Up @@ -77,6 +82,9 @@ pub enum DumpError {
#[cfg(feature = "dump_load_files")]
#[display("Invalid extension for output file.")]
InvalidExtension {},
#[cfg(feature = "encryption")]
#[display("Encryption error: {_0}")]
EncryptionError(encrypt::EncryptError),
}

// These tests are also called from the load tests.
Expand Down
106 changes: 106 additions & 0 deletions rust/avdschema/src/utils/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ where
Ok(serde_json::from_str(json)?)
}

/// Decrypt and deserialize JSON data encrypted with AES-256-GCM.
/// Expected format: nonce (12 bytes) || ciphertext (includes 16-byte auth tag)
#[cfg(feature = "encryption")]
fn from_encrypted_json(data: &[u8], key: &[u8; 32]) -> Result<Self, LoadError> {
let plaintext = encrypt::decrypt(data, key)?;
let json = std::str::from_utf8(&plaintext)?;
Self::from_json(json)
}

#[cfg(feature = "dump_load_files")]
fn from_file(input: Option<&Path>) -> Result<Self, LoadError> {
// Read input from file / stdin
Expand Down Expand Up @@ -120,6 +129,12 @@ pub enum LoadError {
#[cfg(feature = "dump_load_files")]
#[display("No files found.")]
NoFilesFound {},
#[cfg(feature = "encryption")]
#[display("Decryption error: {_0}")]
DecryptionError(encrypt::EncryptError),
#[cfg(feature = "encryption")]
#[display("Invalid UTF-8 in decrypted data: {_0}")]
Utf8Error(std::str::Utf8Error),
}

#[cfg(test)]
Expand Down Expand Up @@ -175,4 +190,95 @@ mod tests {
assert!(result.is_ok());
assert_eq!(result.unwrap(), store);
}

#[cfg(feature = "encryption")]
mod encryption_tests {
use super::*;
use crate::utils::dump::Dump;

#[test]
fn encrypt_decrypt_roundtrip_schema() {
let key: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1d, 0x1e, 0x1f, 0x20,
];
let schema = get_test_dict_schema();

// Encrypt
let encrypted = schema.to_encrypted_json(&key);
assert!(encrypted.is_ok(), "Encryption failed: {:?}", encrypted.err());
let encrypted_data = encrypted.unwrap();

// Verify encrypted data has expected minimum size (12 nonce + 16 tag + some data)
assert!(encrypted_data.len() >= 28, "Encrypted data too short");

// Decrypt
let decrypted: Result<AnySchema, _> = AnySchema::from_encrypted_json(&encrypted_data, &key);
assert!(decrypted.is_ok(), "Decryption failed: {:?}", decrypted.err());
assert_eq!(decrypted.unwrap(), schema);
}

#[test]
fn encrypt_decrypt_roundtrip_store() {
let key: [u8; 32] = [
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55,
0x66, 0x77, 0x88, 0x99,
];
let store = get_test_store();

// Encrypt
let encrypted = store.to_encrypted_json(&key);
assert!(encrypted.is_ok(), "Encryption failed: {:?}", encrypted.err());
let encrypted_data = encrypted.unwrap();

// Decrypt
let decrypted: Result<Store, _> = Store::from_encrypted_json(&encrypted_data, &key);
assert!(decrypted.is_ok(), "Decryption failed: {:?}", decrypted.err());
assert_eq!(decrypted.unwrap(), store);
}

#[test]
fn decrypt_with_wrong_key_fails() {
let key: [u8; 32] = [0x01; 32];
let wrong_key: [u8; 32] = [0x02; 32];
let schema = get_test_dict_schema();

// Encrypt with correct key
let encrypted = schema.to_encrypted_json(&key).unwrap();

// Decrypt with wrong key should fail
let decrypted: Result<AnySchema, _> = AnySchema::from_encrypted_json(&encrypted, &wrong_key);
assert!(decrypted.is_err(), "Decryption should fail with wrong key");
}

#[test]
fn decrypt_truncated_data_fails() {
let key: [u8; 32] = [0x01; 32];

// Data too short (less than nonce + tag + 1 byte)
let short_data = vec![0u8; 20];
let result: Result<AnySchema, _> = AnySchema::from_encrypted_json(&short_data, &key);
assert!(result.is_err(), "Decryption should fail with truncated data");
}

#[test]
fn decrypt_corrupted_data_fails() {
let key: [u8; 32] = [0x01; 32];
let schema = get_test_dict_schema();

// Encrypt
let mut encrypted = schema.to_encrypted_json(&key).unwrap();

// Corrupt the ciphertext (not the nonce)
if encrypted.len() > 15 {
encrypted[15] ^= 0xff;
}

// Decrypt should fail due to authentication failure
let decrypted: Result<AnySchema, _> = AnySchema::from_encrypted_json(&encrypted, &key);
assert!(decrypted.is_err(), "Decryption should fail with corrupted data");
}
}
}
9 changes: 9 additions & 0 deletions rust/encrypt/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "encrypt"
version.workspace = true
edition = "2024"
license.workspace = true

[dependencies]
aes-gcm = { version = "0.10.3", features = ["std"] }
derive_more = { workspace = true, features = ["display", "from"] }
Loading
Loading