Skip to content

Commit c5138ff

Browse files
Feat(passwords): Implement type 7 passwords for simple obfuscation (#29)
1 parent 5b7cfcc commit c5138ff

9 files changed

Lines changed: 555 additions & 2 deletions

File tree

pyavd_utils/passwords.pyi

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,39 @@ def cbc_verify(key: str, encrypted_data: str) -> str:
6565
Returns:
6666
bool: `True` if the password is decryptable, `False` otherwise.
6767
"""
68+
69+
def simple_7_encrypt(data: str, salt: int | None) -> str:
70+
"""
71+
Encrypt (obfuscate) a password with insecure type-7.
72+
73+
WARNING: Type-7 encryption is NOT secure and should only be used for compatibility
74+
with legacy systems. It provides only obfuscation, not real encryption.
75+
76+
Args:
77+
data: The password to encrypt.
78+
salt: The salt value (0-15). If None, a random salt will be generated.
79+
80+
Returns:
81+
str: The encrypted password in type-7 format.
82+
83+
Raises:
84+
ValueError: If the salt is not in the range 0-15.
85+
"""
86+
87+
def simple_7_decrypt(data: str) -> str:
88+
"""
89+
Decrypt (deobfuscate) a password from insecure type-7.
90+
91+
WARNING: Type-7 encryption is NOT secure and should only be used for compatibility
92+
with legacy systems. It provides only obfuscation, not real encryption.
93+
94+
Args:
95+
data: The type-7 encrypted password to decrypt.
96+
97+
Returns:
98+
str: The decrypted password.
99+
100+
Raises:
101+
ValueError: If the encrypted data is invalid (too short, invalid format, invalid hex, or salt out of range).
102+
RuntimeError: If the decrypted data is not valid UTF-8.
103+
"""

rust/passwords/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ edition = "2024"
55
license.workspace = true
66

77
[features]
8-
default = ["cbc", "sha512"]
8+
default = ["cbc", "sha512", "simple-7"]
99
cbc = ["dep:cbc", "dep:cipher", "dep:des", "dep:base64"]
1010
sha512 = ["dep:sha-crypt"]
11+
simple-7 = ["dep:hex", "dep:rand"]
1112

1213
[dependencies]
1314
derive_more = { workspace = true, features = ["display", "from"]}
@@ -18,3 +19,6 @@ cbc = { version = "0.1.0", default-features = false , optional = true}
1819
cipher = { version = "0.4.4", default-features = false, features = ["block-padding"] , optional = true}
1920
des = { version = "0.8.0", default-features = false , optional = true}
2021
base64 = { version = "0.21.0", default-features = false , features = ["std"], optional = true}
22+
# simple-7 feature
23+
hex = { version = "0.4", default-features = false, features = ["std"], optional = true}
24+
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"], optional = true}

rust/passwords/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,11 @@ mod cbc;
1818

1919
#[cfg(feature = "cbc")]
2020
pub use crate::cbc::{CbcError, cbc_check_password, cbc_decrypt, cbc_encrypt};
21+
22+
// Feature simple-7
23+
24+
#[cfg(feature = "simple-7")]
25+
mod simple_7;
26+
27+
#[cfg(feature = "simple-7")]
28+
pub use crate::simple_7::{Simple7Error, simple_7_decrypt, simple_7_encrypt};

rust/passwords/src/simple_7/mod.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2025 Arista Networks, Inc.
2+
// Use of this source code is governed by the Apache License 2.0
3+
// that can be found in the LICENSE file.
4+
5+
use rand::Rng;
6+
7+
const SIMPLE_7_SEED: &[u8] = b"dsfd;kfoA,.iyewrkldJKDHSUBsgvca69834ncxv9873254k;fg87";
8+
9+
#[derive(Debug, derive_more::Display, derive_more::From)]
10+
pub enum Simple7Error {
11+
#[display("Invalid salt format in encrypted data")]
12+
InvalidSaltFormat(std::num::ParseIntError),
13+
#[display("Invalid hex encoding in encrypted data")]
14+
InvalidHexEncoding(hex::FromHexError),
15+
#[display("Decrypted data is not valid UTF-8")]
16+
InvalidUtf8(std::string::FromUtf8Error),
17+
#[display("Salt must be in the range 0-15, got {_0}")]
18+
InvalidSaltValue(u8),
19+
#[display("Encrypted data too short (minimum 2 characters required for salt)")]
20+
DataTooShort,
21+
}
22+
impl std::error::Error for Simple7Error {}
23+
24+
/// Decrypt (deobfuscate) a password from insecure type-7.
25+
pub fn simple_7_decrypt(data: &str) -> Result<String, Simple7Error> {
26+
if data.len() < 2 {
27+
return Err(Simple7Error::DataTooShort);
28+
}
29+
30+
let salt = data[0..2].parse::<usize>()?;
31+
32+
// Validate salt is in valid range (0-15)
33+
if salt > 15 {
34+
return Err(Simple7Error::InvalidSaltValue(salt as u8));
35+
}
36+
37+
let secret = hex::decode(&data[2..])?;
38+
39+
let decrypted: Vec<u8> = secret
40+
.iter()
41+
.enumerate()
42+
.map(|(i, &byte)| byte ^ SIMPLE_7_SEED[(salt + i) % 53])
43+
.collect();
44+
45+
Ok(String::from_utf8(decrypted)?)
46+
}
47+
48+
/// Encrypt (obfuscate) a password with insecure type-7.
49+
///
50+
/// If `salt` is `None`, a random salt in the range 0-15 will be used.
51+
/// Returns an error if the provided salt is not in the range 0-15.
52+
pub fn simple_7_encrypt(data: &str, salt: Option<u8>) -> Result<String, Simple7Error> {
53+
let salt = match salt {
54+
Some(s) if s > 15 => return Err(Simple7Error::InvalidSaltValue(s)),
55+
Some(s) => s,
56+
None => rand::thread_rng().gen_range(0..16),
57+
};
58+
59+
let cleartext = data.as_bytes();
60+
61+
let encrypted: Vec<u8> = cleartext
62+
.iter()
63+
.enumerate()
64+
.map(|(i, &byte)| byte ^ SIMPLE_7_SEED[(salt as usize + i) % 53])
65+
.collect();
66+
67+
Ok(format!("{:02}{}", salt, hex::encode_upper(encrypted)))
68+
}
69+
70+
#[cfg(test)]
71+
mod tests {
72+
use super::*;
73+
74+
const TEST_PASSWORD: &str = "foo";
75+
76+
// (salt, encrypted_password) pairs for TEST_PASSWORD
77+
const VALID_ENCRYPT_DECRYPT_PAIRS: [(u8, &str); 7] = [
78+
(1, "0115090B"),
79+
(6, "0600002E"),
80+
(9, "094A4106"),
81+
(3, "03025404"),
82+
(12, "121F0A18"),
83+
(10, "10480616"),
84+
(15, "15140403"),
85+
];
86+
87+
// Invalid salt values for encryption
88+
const INVALID_SALT_VALUES: [u8; 3] = [16, 99, 255];
89+
90+
#[test]
91+
fn test_simple_7_encrypt_ok() {
92+
for (salt, expected) in VALID_ENCRYPT_DECRYPT_PAIRS {
93+
let result = simple_7_encrypt(TEST_PASSWORD, Some(salt))
94+
.expect("Encryption failed");
95+
assert_eq!(result, expected, "Failed for salt {}", salt);
96+
}
97+
}
98+
99+
#[test]
100+
fn test_simple_7_decrypt_ok() {
101+
for (salt, encrypted) in VALID_ENCRYPT_DECRYPT_PAIRS {
102+
let result = simple_7_decrypt(encrypted)
103+
.expect("Decryption failed");
104+
assert_eq!(result, TEST_PASSWORD, "Failed for salt {}", salt);
105+
}
106+
}
107+
108+
#[test]
109+
fn test_simple_7_encrypt_decrypt_roundtrip() {
110+
let original = "test_password_123";
111+
let encrypted = simple_7_encrypt(original, Some(5))
112+
.expect("Encryption failed");
113+
let decrypted = simple_7_decrypt(&encrypted)
114+
.expect("Decryption failed");
115+
assert_eq!(decrypted, original);
116+
}
117+
118+
#[test]
119+
fn test_simple_7_encrypt_random_salt() {
120+
let result = simple_7_encrypt(TEST_PASSWORD, None)
121+
.expect("Encryption with random salt failed");
122+
// Should be 2 chars for salt + hex encoded data
123+
assert!(result.len() >= 2);
124+
// Should be able to decrypt it back
125+
let decrypted = simple_7_decrypt(&result)
126+
.expect("Decryption failed");
127+
assert_eq!(decrypted, TEST_PASSWORD);
128+
}
129+
130+
#[test]
131+
fn test_simple_7_encrypt_invalid_salt() {
132+
for salt in INVALID_SALT_VALUES {
133+
let result = simple_7_encrypt(TEST_PASSWORD, Some(salt));
134+
assert!(result.is_err(), "Expected error for salt {}", salt);
135+
assert!(
136+
matches!(result.unwrap_err(), Simple7Error::InvalidSaltValue(_)),
137+
"Expected InvalidSaltValue error for salt {}",
138+
salt
139+
);
140+
}
141+
}
142+
143+
#[test]
144+
fn test_simple_7_decrypt_data_too_short() {
145+
let result = simple_7_decrypt("");
146+
assert!(matches!(result.unwrap_err(), Simple7Error::DataTooShort));
147+
148+
let result = simple_7_decrypt("0");
149+
assert!(matches!(result.unwrap_err(), Simple7Error::DataTooShort));
150+
}
151+
152+
#[test]
153+
fn test_simple_7_decrypt_invalid_hex() {
154+
let result = simple_7_decrypt("01GGGG");
155+
assert!(matches!(result.unwrap_err(), Simple7Error::InvalidHexEncoding(_)));
156+
}
157+
158+
#[test]
159+
fn test_simple_7_decrypt_invalid_salt() {
160+
// Invalid salt format (not a number)
161+
let result = simple_7_decrypt("XX1234");
162+
assert!(matches!(result.unwrap_err(), Simple7Error::InvalidSaltFormat(_)));
163+
164+
// Salt out of range (0-15)
165+
let result = simple_7_decrypt("161234");
166+
assert!(matches!(result.unwrap_err(), Simple7Error::InvalidSaltValue(16)));
167+
168+
let result = simple_7_decrypt("991234");
169+
assert!(matches!(result.unwrap_err(), Simple7Error::InvalidSaltValue(99)));
170+
}
171+
}

rust/pypasswords/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ pyo3 = { workspace = true, "features" = ["abi3-py310"] }
1212
passwords = { path = "../passwords", default-features = false }
1313

1414
[features]
15-
default = ["cbc", "sha512"]
15+
default = ["cbc", "sha512", "simple-7"]
1616
cbc = ["passwords/cbc"]
1717
sha512 = ["passwords/sha512"]
18+
simple-7 = ["passwords/simple-7"]
1819

1920
[lib]
2021
crate-type = ["cdylib", "lib"]

rust/pypasswords/src/lib.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ mod passwords {
6464
pub fn cbc_verify(password: String, encrypted_data: String) -> bool {
6565
passwords::cbc_check_password(password.as_bytes(), encrypted_data.as_bytes())
6666
}
67+
68+
#[cfg(feature = "simple-7")]
69+
#[pyfunction]
70+
/// Encrypt (obfuscate) a password with insecure type-7.
71+
///
72+
/// If salt is None, a random salt in the range 0-15 will be used.
73+
pub fn simple_7_encrypt(data: String, salt: Option<u8>) -> PyResult<String> {
74+
passwords::simple_7_encrypt(&data, salt).map_err(|err| match err {
75+
passwords::Simple7Error::InvalidSaltValue(_) => PyValueError::new_err(err.to_string()),
76+
_ => PyRuntimeError::new_err(err.to_string()),
77+
})
78+
}
79+
80+
#[cfg(feature = "simple-7")]
81+
#[pyfunction]
82+
/// Decrypt (deobfuscate) a password from insecure type-7.
83+
pub fn simple_7_decrypt(data: String) -> PyResult<String> {
84+
passwords::simple_7_decrypt(&data).map_err(|err| match err {
85+
passwords::Simple7Error::InvalidUtf8(_) => PyRuntimeError::new_err(err.to_string()),
86+
_ => PyValueError::new_err(err.to_string()),
87+
})
88+
}
89+
90+
6791
}
6892

6993
// Implementation of the pytests but here using pyo3 wrappers in Rust, to ensure we get coverage data

rust/pypasswords/src/tests/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ mod test_sha512;
3535

3636
#[cfg(feature = "cbc")]
3737
mod test_cbc;
38+
39+
#[cfg(feature = "simple-7")]
40+
mod test_simple_7;

0 commit comments

Comments
 (0)