Skip to content

Commit dea8b7e

Browse files
ankenyrdhalperi
authored andcommitted
Adding the ability to encrypt/decrypt type 9 Juniper secrets. This resolves at least part of intentionet#16
1 parent 2fe8e03 commit dea8b7e

File tree

5 files changed

+170
-2
lines changed

5 files changed

+170
-2
lines changed

netconan/sensitive_item_removal.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from binascii import b2a_hex
2222
from enum import Enum
2323
from hashlib import md5
24+
from netconan.utils import juniper_secrets
2425

2526
# Using passlib for digests not supported by hashlib
2627
from passlib.hash import cisco_type7, md5_crypt, sha512_crypt
@@ -278,7 +279,7 @@ def _anonymize_value(raw_val, lookup, reserved_words):
278279
# TODO(https://github.com/intentionet/netconan/issues/16)
279280
# Encode base anon_val instead of just returning a constant here
280281
# This value corresponds to encoding: Conan812183
281-
anon_val = "$9$0000IRc-dsJGirewg4JDj9At0RhSreK8Xhc"
282+
anon_val = juniper_secrets.juniper_encrypt(anon_val)
282283

283284
lookup[val] = anon_val
284285
logging.debug('Anonymized input "%s" to "%s"', val, anon_val)

netconan/utils/__init__.py

Whitespace-only changes.

netconan/utils/juniper_secrets.py

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Encrypts and decryptes Juniper Type 9 hashes."""
2+
3+
import random
4+
import re
5+
6+
MAGIC = "$9$"
7+
8+
FAMILY = [
9+
"QzF3n6/9CAtpu0O",
10+
"B1IREhcSyrleKvMW8LXx",
11+
"7N-dVbwsY2g4oaJZGUDj",
12+
"iHkq.mPf5T"
13+
]
14+
15+
EXTRA = {c: (3 - fam) for fam, chars in enumerate(FAMILY) for c in chars}
16+
17+
NUM_ALPHA = list("".join(FAMILY).replace('-','')+'-')
18+
ALPHA_NUM = {char: idx for idx, char in enumerate(NUM_ALPHA)}
19+
20+
ENCODING = [
21+
[1, 4, 32],
22+
[1, 16, 32],
23+
[1, 8, 32],
24+
[1, 64],
25+
[1, 32],
26+
[1, 4, 16, 128],
27+
[1, 32, 64]
28+
]
29+
30+
VALID = f"^{MAGIC}[{''.join(NUM_ALPHA)}]{{4,}}$".replace("$", r"\$", 2)
31+
32+
33+
def juniper_decrypt(crypt):
34+
"""Decrypts a Juniper $9 encrypted secret.
35+
36+
Args:
37+
crypt: String containing the secret to decrypt.
38+
39+
Returns:
40+
String representing the decrypted secret.
41+
"""
42+
if not crypt or not re.search(VALID, crypt):
43+
raise ValueError("Invalid Juniper crypt string!")
44+
45+
chars = crypt[len(MAGIC):]
46+
first, chars = _nibble(chars, 1)
47+
_, chars = _nibble(chars, EXTRA[first])
48+
49+
prev = first
50+
decrypt = ""
51+
52+
while chars:
53+
decode = ENCODING[len(decrypt) % len(ENCODING)]
54+
nibble_len = len(decode)
55+
nibble, chars = _nibble(chars, nibble_len)
56+
gaps = []
57+
for i in enumerate(nibble):
58+
gaps.append(_gap(prev, nibble[i]))
59+
prev = nibble[i]
60+
decrypt += _gap_decode(gaps, decode)
61+
return decrypt
62+
63+
def _nibble(chars, length):
64+
nib = chars[:length]
65+
chars = chars[length:]
66+
67+
return nib, chars
68+
69+
def _gap(c1, c2):
70+
diff = ALPHA_NUM[c2] - ALPHA_NUM[c1]
71+
pos_diff = diff + len(NUM_ALPHA)
72+
return pos_diff % len(NUM_ALPHA) - 1
73+
74+
def _gap_decode(gaps, dec):
75+
if len(gaps) != len(dec):
76+
raise ValueError("Nibble and decode size not the same!")
77+
78+
num = sum(g * d for g, d in zip(gaps, dec))
79+
return chr(num % 256)
80+
81+
def juniper_encrypt(plain, salt=None):
82+
"""Encrypts a Juniper $9 encrypted secret.
83+
84+
Args:
85+
plain: String containing the plaintext secret to be encrypted.
86+
salt: Optional salt to be used when encrypting the secret.
87+
88+
Returns:
89+
String representing the encrypted secret.
90+
"""
91+
if salt is None:
92+
salt = _randc(1)
93+
rand = _randc(EXTRA[salt])
94+
95+
pos = 0
96+
prev = salt
97+
crypt = f"{MAGIC}{salt}{rand}"
98+
for p in plain:
99+
encode = ENCODING[pos % len(ENCODING)]
100+
crypt += _gap_encode(p, prev, encode)
101+
prev = crypt[-1]
102+
pos += 1
103+
return crypt
104+
105+
def _randc(count):
106+
return ''.join(random.choice(NUM_ALPHA) for _ in range(count))
107+
108+
def _gap_encode(pc, prev, enc):
109+
ord_val = ord(pc)
110+
crypt = ""
111+
gaps = []
112+
113+
for mod in reversed(enc):
114+
gaps.insert(0, ord_val // mod)
115+
ord_val %= mod
116+
117+
for gap in gaps:
118+
gap += ALPHA_NUM[prev] + 1
119+
prev = NUM_ALPHA[gap % len(NUM_ALPHA)]
120+
crypt += prev
121+
122+
return crypt

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
python_requires=">=3.8",
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/unit/test_juniper_secrets.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests Juniper secret encryption/decryption methods."""
2+
from unittest.mock import MagicMock
3+
import pytest
4+
from netconan.utils import juniper_secrets
5+
6+
@pytest.mark.parametrize(
7+
"plain_text, expected",
8+
[
9+
("abc", "$9$aaZGiq.5zF/"),
10+
("123", "$9$aaZikmfTF69"),
11+
("netconan", "$9$aaGi.-QnAu1Di6Au1yrevWXds"),
12+
("QjEf.WloKY4IBVGik9xeIO9xWN5F7S13",
13+
"$9$aaZiq3nCOBRqmApBIyrdbs4ZjQzn/t0zFuBRErlJZUHqP6/tO1h0Ob24aiHQz39pOrevMXdfT01RhrlLX7b4aUjk.fTGU")
14+
]
15+
)
16+
def test_juniper_encrypt(plain_text, expected):
17+
"""Test encryption of secrets."""
18+
juniper_secrets._randc = MagicMock(return_value='a')
19+
assert juniper_secrets.juniper_encrypt(plain_text) == expected
20+
21+
22+
@pytest.mark.parametrize(
23+
"encrypted, expected",
24+
[
25+
("$9$aaZGiq.5zF/", "abc"),
26+
("$9$aaZikmfTF69", "123"),
27+
("$9$aaGi.-QnAu1Di6Au1yrevWXds", "netconan"),
28+
("$9$aaZiq3nCOBRqmApBIyrdbs4ZjQzn/t0zFuBRErlJZUHqP6/tO1h0Ob24aiHQz39pOrevMXdfT01RhrlLX7b4aUjk.fTGU", "QjEf.WloKY4IBVGik9xeIO9xWN5F7S13")
29+
]
30+
)
31+
def test_juniper_decrypt(encrypted, expected):
32+
"""Test decryption of secrets."""
33+
assert juniper_secrets.juniper_decrypt(encrypted) == expected
34+
35+
@pytest.mark.parametrize(
36+
"encrypted",
37+
[
38+
("abcd"),
39+
("$9$aaGi.Qz6t0IDi/t0IrlKM8x,s")
40+
]
41+
)
42+
def test_invalid_juniper_decrypt(encrypted):
43+
"""Test raising errors when decrypting invalid secrets."""
44+
with pytest.raises(ValueError):
45+
juniper_secrets.juniper_decrypt(encrypted)

0 commit comments

Comments
 (0)