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
73 changes: 73 additions & 0 deletions pyavd_utils/passwords.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,76 @@ 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.).
"""

def vault_encrypt(data: bytes, password: str, vault_id: str | None = None) -> str:
"""
Encrypt data using Ansible Vault format.

Args:
data: The plaintext data to encrypt.
password: The password to use for encryption.
vault_id: Optional vault ID label. If provided, uses v1.2 format with the vault ID.
If None or empty, uses v1.1 format without a vault ID.

Returns:
str: The encrypted data as a string in Ansible Vault format.

Raises:
RuntimeError: If encryption fails.
"""

def vault_decrypt(vault_data: str, password: str) -> tuple[bytes, str | None]:
"""
Decrypt data from Ansible Vault v1.1/v1.2 format.

Args:
vault_data: The encrypted vault data as a string.
password: The password to use for decryption.

Returns:
tuple[bytes, str | None]: A tuple of (decrypted_data, vault_id).
vault_id is None for v1.1 format (no vault ID in header).

Raises:
RuntimeError: If decryption fails (wrong password, invalid format, HMAC mismatch, etc.).
"""
32 changes: 25 additions & 7 deletions pyavd_utils/validation.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,29 @@ 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_password: str | None
"""
Optional password for Ansible Vault encryption/decryption.
When set, input data is expected to be encrypted in Ansible Vault format
and output data will be encrypted in the same format.
"""

encryption_vault_id: str | None
"""
Optional vault ID for Ansible Vault encryption.
If set, uses v1.2 format with the vault ID in the header.
If not set, uses v1.1 format without a vault ID.
"""

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_password: str | None = None,
encryption_vault_id: str | None = None,
) -> None: ...

class Violation:
Expand Down Expand Up @@ -72,10 +88,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 Ansible Vault encrypted bytes if encryption_password was provided)."""

def init_store_from_file(file: Path) -> None:
"""
Expand All @@ -92,15 +109,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 Ansible Vault encrypted data if encryption_password is set in configuration).
schema_name: The name of the schema to validate against.
configuration: Optional configuration for validation behavior.

Expand All @@ -109,17 +126,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_password is set in configuration, both input and output data are encrypted using Ansible Vault v1.2 format.

Args:
data_as_json: Structured data dumped as JSON.
data: Structured data as UTF-8 encoded JSON (or Ansible Vault encrypted data if encryption_password 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, vault_decrypt, vault_encrypt};
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");
}
}
}
Loading
Loading