Skip to content

Commit 81fb50d

Browse files
authored
Encrypt/Decrypt Juniper $9 secrets. (#195)
Adding the ability to encrypt/decrypt type 9 Juniper secrets. This resolves at least part of #16 Include verifiable permission and license to use the code.
1 parent f915cd8 commit 81fb50d

9 files changed

+762
-34
lines changed

LICENSE.email

+475
Large diffs are not rendered by default.

netconan/anonymize_files.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def anonymize_io(self, in_io, out_io):
117117
output_line = line
118118
if self.compiled_regexes is not None and self.pwd_lookup is not None:
119119
output_line = replace_matching_item(
120-
self.compiled_regexes, output_line, self.pwd_lookup
120+
self.compiled_regexes, output_line, self.pwd_lookup, self.salt
121121
)
122122

123123
if self.anonymizer6 is not None:

netconan/sensitive_item_removal.py

+24-11
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
# Using passlib for digests not supported by hashlib
2626
from passlib.hash import cisco_type7, md5_crypt, sha512_crypt
2727

28+
from netconan.utils import juniper_secrets
29+
2830
from .default_pwd_regexes import default_com_line_regexes, default_pwd_line_regexes
2931
from .default_reserved_words import default_reserved_words
3032

@@ -232,7 +234,7 @@ def anonymize_as_numbers(anonymizer, line):
232234
return as_number_regex.sub(lambda match: anonymizer.anonymize(match.group(0)), line)
233235

234236

235-
def _anonymize_value(raw_val, lookup, reserved_words):
237+
def _anonymize_value(raw_val, lookup, reserved_words, salt):
236238
"""Generate an anonymized replacement for the input value.
237239
238240
This function tries to determine what type of value was passed in and
@@ -248,12 +250,20 @@ def _anonymize_value(raw_val, lookup, reserved_words):
248250
if not val:
249251
logging.debug("Nothing to anonymize after removing special characters")
250252
return raw_val
251-
253+
decrypted = None
254+
if val.startswith(juniper_secrets.MAGIC):
255+
try:
256+
decrypted = juniper_secrets.juniper_decrypt(val)
257+
except ValueError:
258+
pass
252259
if val in lookup:
253260
anon_val = lookup[val]
254261
logging.debug('Anonymized input "%s" to "%s" (via lookup)', val, anon_val)
255262
return sens_head + anon_val + sens_tail
256-
263+
elif decrypted in lookup:
264+
anon_val = juniper_secrets.juniper_nonrandom_encrypt(lookup[decrypted], salt)
265+
logging.debug('Anonymized input "%s" to "%s" (via lookup)', val, anon_val)
266+
return sens_head + anon_val + sens_tail
257267
anon_val = "netconanRemoved{}".format(len(lookup))
258268
item_format = _check_sensitive_item_format(val)
259269
if item_format == _sensitive_item_formats.cisco_type7:
@@ -280,12 +290,11 @@ def _anonymize_value(raw_val, lookup, reserved_words):
280290
anon_val = sha512_crypt.using(rounds=5000).hash(anon_val)
281291

282292
if item_format == _sensitive_item_formats.juniper_type9:
283-
# TODO(https://github.com/intentionet/netconan/issues/16)
284-
# Encode base anon_val instead of just returning a constant here
285-
# This value corresponds to encoding: Conan812183
286-
anon_val = "$9$0000IRc-dsJGirewg4JDj9At0RhSreK8Xhc"
287-
288-
lookup[val] = anon_val
293+
anon_val = juniper_secrets.juniper_nonrandom_encrypt(anon_val, salt)
294+
if decrypted:
295+
lookup[decrypted] = juniper_secrets.juniper_decrypt(anon_val)
296+
else:
297+
lookup[val] = anon_val
289298
logging.debug('Anonymized input "%s" to "%s"', val, anon_val)
290299
return sens_head + anon_val + sens_tail
291300

@@ -343,7 +352,11 @@ def generate_default_sensitive_item_regexes():
343352

344353

345354
def replace_matching_item(
346-
compiled_regexes, input_line, pwd_lookup, reserved_words=default_reserved_words
355+
compiled_regexes,
356+
input_line,
357+
pwd_lookup,
358+
salt,
359+
reserved_words=default_reserved_words,
347360
):
348361
"""If line matches a regex, anonymize or remove the line."""
349362
# Collapse whitespace to simplify regexes, also preserve leading and trailing whitespace
@@ -383,7 +396,7 @@ def replace_matching_item(
383396
# re.sub replaces the entire matching string, which includes prefix
384397
# Therefore, anon_val should have prefix prepended if applicable
385398
anon_val = prefix + _anonymize_value(
386-
match.group(sensitive_item_num), pwd_lookup, reserved_words
399+
match.group(sensitive_item_num), pwd_lookup, reserved_words, salt
387400
)
388401
output_line = compiled_re.sub(anon_val, output_line)
389402

netconan/utils/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Utilities for netconan."""

netconan/utils/juniper_secrets.py

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Encrypts and decryptes Juniper Type 9 hashes.
2+
3+
This module is a derivative work based on the perl module Crypt-Juniper by Kevin Brintnall.
4+
https://metacpan.org/dist/Crypt-Juniper/view/lib/Crypt/Juniper.pm
5+
Used under the Artistic License https://dev.perl.org/licenses/artistic.html
6+
7+
This work was modified from the original in the following ways:
8+
2025-01-21: Rob Ankeny(ankeny at gmail.com): Translated from perl to python.
9+
2025-01-23: Rob Ankeny(ankeny at gmail.com): Updated methods to have non-random outputs
10+
for the benefit of deterministic outputs. Updated docstrings and method names to
11+
better reflect these changes.
12+
"""
13+
14+
import re
15+
from typing import List, Tuple
16+
17+
MAGIC = "$9$"
18+
19+
FAMILY = [
20+
"QzF3n6/9CAtpu0O",
21+
"B1IREhcSyrleKvMW8LXx",
22+
"7N-dVbwsY2g4oaJZGUDj",
23+
"iHkq.mPf5T",
24+
]
25+
26+
EXTRA = {c: (3 - fam) for fam, chars in enumerate(FAMILY) for c in chars}
27+
28+
NUM_ALPHA = list("".join(FAMILY))
29+
ALPHA_NUM = {char: idx for idx, char in enumerate(NUM_ALPHA)}
30+
31+
32+
ENCODING = [
33+
[1, 4, 32],
34+
[1, 16, 32],
35+
[1, 8, 32],
36+
[1, 64],
37+
[1, 32],
38+
[1, 4, 16, 128],
39+
[1, 32, 64],
40+
]
41+
42+
VALID = rf"^{re.escape(MAGIC)}[{re.escape(''.join(NUM_ALPHA))}]{{4,}}$"
43+
44+
45+
def juniper_decrypt(crypt: str) -> str:
46+
"""Decrypts a Juniper $9 encrypted secret.
47+
48+
Args:
49+
crypt: String containing the secret to decrypt.
50+
51+
Returns:
52+
String representing the decrypted secret.
53+
"""
54+
if not crypt or not re.search(VALID, crypt):
55+
raise ValueError("Invalid Juniper crypt string!")
56+
57+
chars = crypt[len(MAGIC) :]
58+
first, chars = _nibble(chars, 1)
59+
_, chars = _nibble(chars, EXTRA[first])
60+
61+
prev = first
62+
decrypt = ""
63+
64+
while chars:
65+
decode = ENCODING[len(decrypt) % len(ENCODING)]
66+
nibble_len = len(decode)
67+
nibble, chars = _nibble(chars, nibble_len)
68+
gaps = []
69+
for i, _ in enumerate(nibble):
70+
gaps.append(_gap(prev, nibble[i]))
71+
prev = nibble[i]
72+
decrypt += _gap_decode(gaps, decode)
73+
return decrypt
74+
75+
76+
def _nibble(chars: str, length: int) -> Tuple[str, str]:
77+
nib = chars[:length]
78+
chars = chars[length:]
79+
return nib, chars
80+
81+
82+
def _gap(c1: str, c2: str) -> int:
83+
diff = ALPHA_NUM[c2] - ALPHA_NUM[c1]
84+
pos_diff = diff + len(NUM_ALPHA)
85+
return pos_diff % len(NUM_ALPHA) - 1
86+
87+
88+
def _gap_decode(gaps: List[int], dec: List[int]) -> chr:
89+
if len(gaps) != len(dec):
90+
raise ValueError("Nibble and decode size not the same!")
91+
num = sum(g * d for g, d in zip(gaps, dec))
92+
return chr(num % 256)
93+
94+
95+
def juniper_nonrandom_encrypt(plain: str, salt: str = None) -> str:
96+
"""Encrypts a Juniper $9 encrypted secret.
97+
98+
Juniper encryption takes in a salt which should be a single character.
99+
If not present it generates a random single alpha-numeric character.
100+
It then uses this salt to get a random number between 0 and 3 from
101+
the EXTRA dictionary. This number is then used to generate 0-3 random
102+
characters from the family of characters.
103+
Because netconan isn't actually creating true passwords that are to be used
104+
in the wild and is about sharing, it is safe to ignore the randomness
105+
in favor of generating predictable outputs.
106+
107+
Args:
108+
plain: String containing the plaintext secret to be encrypted.
109+
salt: Optional salt to be used when encrypting the secret.
110+
111+
Returns:
112+
String representing the encrypted secret.
113+
"""
114+
if salt is None:
115+
salt = _fixedc(1)
116+
salt = salt[0]
117+
rand = _fixedc(EXTRA[salt])
118+
119+
pos = 0
120+
prev = salt
121+
crypt = f"{MAGIC}{salt}{rand}"
122+
for p in plain:
123+
encode = ENCODING[pos % len(ENCODING)]
124+
crypt += _gap_encode(p, prev, encode)
125+
prev = crypt[-1]
126+
pos += 1
127+
return crypt
128+
129+
130+
def _fixedc(count: int) -> str:
131+
if count == 3:
132+
return "net"
133+
elif count == 2:
134+
return "ne"
135+
elif count == 1:
136+
return "n"
137+
else:
138+
return ""
139+
140+
141+
def _gap_encode(pc: str, prev: str, enc: List[int]) -> str:
142+
ord_val = ord(pc)
143+
crypt = ""
144+
gaps = []
145+
for mod in reversed(enc):
146+
gaps.insert(0, ord_val // mod)
147+
ord_val %= mod
148+
for gap in gaps:
149+
gap += ALPHA_NUM[prev] + 1
150+
prev = NUM_ALPHA[gap % len(NUM_ALPHA)]
151+
crypt += prev
152+
return crypt

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
python_requires=">=3.9",
7474
# What does your project relate to?
7575
keywords="network configuration anonymizer",
76-
packages=["netconan"],
76+
packages=["netconan", "netconan.utils"],
7777
# Alternatively, if you want to distribute just a my_module.py, uncomment
7878
# this:
7979
# py_modules=["my_module"],

tests/end_to_end/test_end_to_end.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
ip address 11.11.11.11 0.0.0.0
3434
ip address 11.11.197.79 0.0.0.0
3535
# Sensitive word Addr here
36+
pre-shared-key ascii-text "123"; ## SECRET-DATA
37+
pre-shared-key ascii-text "$9$eZkvX7dbs4JG"; ## SECRET-DATA
38+
pre-shared-key ascii-text "$9$qmQF69A01R"; ## SECRET-DATA
3639
3740
"""
3841

@@ -48,6 +51,9 @@
4851
ip address 11.11.11.11 0.0.0.0
4952
ip address 11.11.197.79 0.0.0.0
5053
# 3b836f word 10b348 here
54+
pre-shared-key ascii-text "146741862156641154150793826712647197746"; ## SECRET-DATA
55+
pre-shared-key ascii-text "$9$Tz/C0BIrKMhcs24oGUuOBRreM8X7dbMWDiqmTQcyre8X7-VgaZdVk.P5F3hSyKX7goJDHqZG69tu1IwY24GDHqmF69mPhSrlMWHq.5n/O1RyevRE"; ## SECRET-DATA
56+
pre-shared-key ascii-text "$9$Tz/C0BIrKMhcs24oGUuOBRreM8X7dbMWDiqmTQcyre8X7-VgaZdVk.P5F3hSyKX7goJDHqZG69tu1IwY24GDHqmF69mPhSrlMWHq.5n/O1RyevRE"; ## SECRET-DATA
5157
5258
"""
5359

@@ -63,6 +69,9 @@
6369
ip address 11.11.11.11 0.0.0.0
6470
ip address 11.11.197.79 0.0.0.0
6571
# 3b836f word 10b348 here
72+
pre-shared-key ascii-text "146741862156641154150793826712647197746"; ## SECRET-DATA
73+
pre-shared-key ascii-text "$9$Tz/C0BIrKMhcs24oGUuOBRreM8X7dbMWDiqmTQcyre8X7-VgaZdVk.P5F3hSyKX7goJDHqZG69tu1IwY24GDHqmF69mPhSrlMWHq.5n/O1RyevRE"; ## SECRET-DATA
74+
pre-shared-key ascii-text "$9$Tz/C0BIrKMhcs24oGUuOBRreM8X7dbMWDiqmTQcyre8X7-VgaZdVk.P5F3hSyKX7goJDHqZG69tu1IwY24GDHqmF69mPhSrlMWHq.5n/O1RyevRE"; ## SECRET-DATA
6675
6776
"""
6877

@@ -76,7 +85,6 @@ def run_test(input_dir, output_dir, filename, ref, args):
7685
t_ref = ref.split("\n")
7786
with open(str(output_dir.join(filename))) as f_out:
7887
t_out = f_out.read().split("\n")
79-
8088
# Make sure output file lines match ref lines
8189
assert t_ref == t_out
8290

tests/unit/test_juniper_secrets.py

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Tests Juniper secret encryption/decryption methods."""
2+
3+
import pytest
4+
5+
from netconan.utils import juniper_secrets
6+
7+
8+
@pytest.mark.parametrize(
9+
"plain_text, expected",
10+
[
11+
("abc", "$9$nnet/9pOBEyrv"),
12+
("123", "$9$nnet/p01RhrKM"),
13+
("netconan", "$9$nnet9pBcSe8xdApK8xdg4aZUi.5"),
14+
(
15+
"QjEf.WloKY4IBVGik9xeIO9xWN5F7S13",
16+
"$9$nnet/pOleWN-bO18X-Vg4.P5F/tSyevL7yrx-bw4o6/CuOIKvLNds7NPQFnpuSylMXN4aZGi.Rh7dbs4ojikPFnCt0BRh9C",
17+
),
18+
],
19+
)
20+
def test_juniper_encrypt(plain_text, expected):
21+
"""Test encryption of secrets."""
22+
assert juniper_secrets.juniper_nonrandom_encrypt(plain_text) == expected
23+
24+
25+
@pytest.mark.parametrize(
26+
"encrypted, expected",
27+
[
28+
(
29+
"$9$Ly.x7VYgJH.5SraGiH5TFn/CO1cylW8xs23/Ap1Ibs24aGf5F/A0EcNVs4Dj5QF6/AlKWdsg-VQ3n/tp-Vbs4JTQnCp0Lx",
30+
"asvWWcb54DGWFvEjsENnhB__xY49Mn3R",
31+
),
32+
("$9$CSxptpBREyKvL", "abc"),
33+
("$9$-pV24JGDkmf", "123"),
34+
("$9$sSgJD.mTn9poJQn9pREcylvLN", "netconan"),
35+
(
36+
"$9$hzrSKWws4UDHWLoJDiPftuORSedVs2aGVbZDHkf5cSrvWXY2aUjqGUu1RhKvdVwgJUfTzF/txNGjHqf56/CuRhreM8xNyr",
37+
"QjEf.WloKY4IBVGik9xeIO9xWN5F7S13",
38+
),
39+
],
40+
)
41+
def test_juniper_decrypt(encrypted, expected):
42+
"""Test decryption of secrets."""
43+
assert juniper_secrets.juniper_decrypt(encrypted) == expected
44+
45+
46+
@pytest.mark.parametrize("encrypted", [("abcd"), ("$9$aaGi.Qz6t0IDi/t0IrlKM8x,s")])
47+
def test_invalid_juniper_decrypt(encrypted):
48+
"""Test raising errors when decrypting invalid secrets."""
49+
with pytest.raises(ValueError):
50+
juniper_secrets.juniper_decrypt(encrypted)
51+
52+
53+
def test_encryption_decryption():
54+
"""Test a large set of inputs to detect any possible encrypting/decrypting issues."""
55+
for i in range(0, 1000):
56+
plaintext = str(i)
57+
decrypted = juniper_secrets.juniper_decrypt(
58+
juniper_secrets.juniper_nonrandom_encrypt(plaintext, "t")
59+
)
60+
assert decrypted == plaintext

0 commit comments

Comments
 (0)