Skip to content

Commit 89294d2

Browse files
authored
Merge pull request #8 from antoinekh/juniper-encrypted-password
Add JUNOS encrypted-password ($5$ and $6$ SHA-crypt) support
2 parents 1156ecd + 22f0c7c commit 89294d2

23 files changed

Lines changed: 2381 additions & 44 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. This covers both the Py
44

55
## Unreleased
66

7+
## v0.4.0 - 2026-08-20
8+
9+
### Added
10+
11+
- Juniper/HPE JUNOS `encrypted-password` (`network_secret.juniper_encrypted_password`, `juniper-encrypted-password`): a one-way Unix SHA-crypt hash for local user passwords, written in config as `encrypted-password "$5$salt$hash"; ## SECRET-DATA` (`$5$` sha256-crypt, `$6$` sha512-crypt). `$1$` (md5crypt), which older JUNOS wrote, is rejected by name rather than falling through to a generic parse failure. Accepts the value pasted straight out of a config line, quotes, trailing `;` and `## SECRET-DATA` marker included. An optional `rounds=N$` field is bounded to 1000-100000, mirroring how `juniper8` bounds its iteration count and the Nokia `$2y$` format bounds its bcrypt cost; a non-canonical value such as `rounds=007000` is accepted and echoed back verbatim by `--check`, while `--encrypt` always writes the canonical form, matching `openssl passwd`. No new dependency on either side: `hashlib` and Web Crypto already provide SHA-256/SHA-512.
12+
- The website's Juniper/HPE `encrypted-password` converter, with Hash and Verify tabs and a one-way badge, next to the existing Juniper/HPE `$8$` converter.
13+
- `juniper-encrypted-password` can now produce either SHA-crypt variant: `encrypt(plaintext, salt=None, variant=None)` takes `variant="sha512"` (the default, `$6$`, unchanged) or `variant="sha256"` (`$5$`); the CLI exposes this as `--variant {sha512,sha256}` on `--encrypt`, and the website as a selector in Hash mode. The mechanism is generic rather than hardcoded to this format: `Cipher` gained a `variants` field (`network_secret/types.py`, `web/src/lib/ciphers/types.ts`) that the CLI and `Converter.svelte` render whenever a format declares one. A `variant` argument and an explicitly `$5$`/`$6$`-prefixed `salt` argument are reconciled: they must agree, or `encrypt` raises `ValueError` naming both.
14+
15+
716
## v0.3.0 - 2026-08-19
817

918
### Added

‎README.md‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
[![Python versions](https://img.shields.io/pypi/pyversions/network-secret)](https://pypi.org/project/network-secret/)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
77

8-
Encode, decode, and check network device secrets for Juniper/HPE JunOS, Nokia SR OS, and Cisco IOS, from the command line or Python. `network-secret` is a unified successor to `juniper8-crypt` and `juniper9-crypt`: it covers all eight formats in a single package with a single CLI.
8+
Encode, decode, and check network device secrets for Juniper/HPE JunOS, Nokia SR OS, and Cisco IOS, from the command line or Python. `network-secret` is a unified successor to `juniper8-crypt` and `juniper9-crypt`: it covers all nine formats in a single package with a single CLI.
99

10-
> **Prefer a browser?** Decode, encode, hash and verify all eight formats at **[network-secret.pages.dev](https://network-secret.pages.dev/)**. It runs the same algorithms fully client-side - nothing you type is ever sent to a server.
10+
> **Prefer a browser?** Decode, encode, hash and verify all nine formats at **[network-secret.pages.dev](https://network-secret.pages.dev/)**. It runs the same algorithms fully client-side - nothing you type is ever sent to a server.
1111
1212
## Repository layout
1313

@@ -27,6 +27,7 @@ The two implementations share known-answer vectors, so keeping them in one repo
2727
|--------|---------------|---------------|-------------|
2828
| `$9$` | `juniper9` | `network_secret.juniper9` | Juniper/HPE reversible obfuscation - keyless |
2929
| `$8$` | `juniper8` | `network_secret.juniper8` | Juniper/HPE AES-256-GCM - keyed by master password |
30+
| `$5$`/`$6$` | `juniper-encrypted-password` | `network_secret.juniper_encrypted_password` | Juniper/HPE JUNOS local-user encrypted-password (Unix SHA-crypt) - one-way |
3031
| Nokia custom-hash | `nokia-sros-custom-hash` | `network_secret.nokia_sros_custom_hash` | Nokia SR OS AES-ECB shared-key cipher |
3132
| `$2y$` | `nokia-sros-password` | `network_secret.nokia_sros_password` | Nokia SR OS bcrypt local user password hash - one-way |
3233
| Type 6 | `cisco-type6` | `network_secret.cisco_type6` | Cisco IOS reversible AES + HMAC - keyed by the master key |
@@ -51,7 +52,7 @@ uv add network-secret
5152
## Python API
5253

5354
```python
54-
from network_secret import juniper8, juniper9, nokia_sros_custom_hash, nokia_sros_password
55+
from network_secret import juniper8, juniper9, juniper_encrypted_password, nokia_sros_custom_hash, nokia_sros_password
5556

5657
# Juniper/HPE $9$ (keyless)
5758
cipher9 = juniper9.encrypt("BGPsecret1")
@@ -66,6 +67,20 @@ plain8 = juniper8.decrypt(cipher8, master)
6667
plain_a, plain_b, match = juniper8.check(cipher8, "BGPsecret1", master)
6768
# match is True
6869

70+
# Juniper/HPE JUNOS encrypted-password (Unix SHA-crypt, one-way)
71+
hash_jep = juniper_encrypted_password.encrypt("lab123")
72+
# '$6$OxsofBL3v2.ckv5z$6cnGooSbcNVCY44cBYiK7Kh.5q.TSZm/71tQMzXc/55aHCY0muFlu7fwjpUlRYd8XjxKlYv4LP.yIMBl2ntOn0' (a fresh salt every call, always starting '$6$', the default variant)
73+
hash_jep_sha256 = juniper_encrypted_password.encrypt("lab123", variant="sha256")
74+
# '$5$9DQNhbbsWrky5BZ3$rfUMEf2WdGxGFHdPem4Z/Rj9hGOQKKbVcyuJ2H98eBA' (variant="sha256" produces "$5$" instead; VARIANTS is ("sha512", "sha256"), first entry is the default)
75+
given, recomputed, match = juniper_encrypted_password.check(
76+
"$5$itHMToxg$IAeDKDuWsSCoL2W7fognCW8cyNj4YE9ZhDvCoWFC5y/", "lab123"
77+
)
78+
# match is True
79+
juniper_encrypted_password.decrypt(hash_jep)
80+
# ValueError: JUNOS encrypted-password values are a one-way SHA-crypt hash and cannot be decrypted. Use --check to test a password against one.
81+
juniper_encrypted_password.encrypt("lab123", salt="$5$abcdefgh", variant="sha512")
82+
# ValueError: variant='sha512' conflicts with salt='$5$abcdefgh': the salt's '$5$' prefix names a different format than variant 'sha512' ('$6$'); pass matching values, or only one of them
83+
6984
# Nokia SR OS custom-hash (16/24/32-character shared key)
7085
key = "a3f8d9e112c04b7af1c3e8b92d057a4e"
7186
cipher_nokia = nokia_sros_custom_hash.encrypt("BGPsecret1", key)
@@ -104,7 +119,7 @@ cisco_type8.decrypt(hash8)
104119
# ValueError: Cisco type 8 is a one-way hash and cannot be decrypted. Use --check to test a password against it.
105120
```
106121

107-
All eight `check()` functions return a `tuple[str, str, bool]`. For five of them the two strings are the decrypted plaintexts and whether they match. Nokia SR OS password, Cisco type 8 and Cisco type 9 cannot decrypt anything, so they return the hash you passed in, the hash recomputed from the candidate password, and whether those match. For Cisco type 6 and type 7, the second argument to `check()` is always read as cleartext, because neither format carries a marker that tells it apart from a password.
122+
All nine `check()` functions return a `tuple[str, str, bool]`. For five of them the two strings are the decrypted plaintexts and whether they match. Juniper/HPE encrypted-password, Nokia SR OS password, Cisco type 8 and Cisco type 9 cannot decrypt anything, so they return the hash you passed in, the hash recomputed from the candidate password, and whether those match. For Cisco type 6 and type 7, the second argument to `check()` is always read as cleartext, because neither format carries a marker that tells it apart from a password.
108123

109124
## Command-line usage
110125

@@ -145,6 +160,39 @@ network-secret juniper8 --decrypt '$8$aes256-gcm$...'
145160

146161
> Always quote `$8$` and `$9$` strings with single quotes - the shell expands `$8` and `$9` as positional parameters otherwise.
147162
163+
### Juniper/HPE JUNOS encrypted-password (`$5$`/`$6$`, one-way)
164+
165+
JUNOS stores local user passwords as standard Unix SHA-crypt, written in config as `encrypted-password "$5$salt$hash"; ## SECRET-DATA` (`$5$` is sha256-crypt, `$6$` is sha512-crypt). There is nothing to decrypt, so `--encrypt` hashes with a fresh random salt and `--check` verifies a candidate password by reusing the salt, variant, and round count carried in the value you give it. `$1$` (md5crypt), which older JUNOS wrote, is deliberately not supported and is rejected by name.
166+
167+
```bash
168+
network-secret juniper-encrypted-password --encrypt 'lab123'
169+
network-secret juniper-encrypted-password --check '$5$itHMToxg$IAeDKDuWsSCoL2W7fognCW8cyNj4YE9ZhDvCoWFC5y/' 'lab123'
170+
171+
network-secret juniper-encrypted-password --decrypt '$6$OV3tvDvc$QT3FzP8LWnxLDCfDLKibn1.EzGS5crupCiea7Kbi2W4Y9Z1kYu9EYTxs/z344U8Mwh4QtwlnbUiLZWUHyhrZl1'
172+
# error: JUNOS encrypted-password values are a one-way SHA-crypt hash and cannot be decrypted. Use --check to test a password against one.
173+
```
174+
175+
You can paste the value straight out of a JUNOS config, quotes, trailing `;` and `## SECRET-DATA` marker included, rather than trimming it down to the bare `$5$.../$6$...` value first:
176+
177+
```bash
178+
network-secret juniper-encrypted-password --check '"$5$itHMToxg$IAeDKDuWsSCoL2W7fognCW8cyNj4YE9ZhDvCoWFC5y/"; ## SECRET-DATA' 'lab123'
179+
```
180+
181+
`--encrypt` picks the hash variant with `--variant {sha512,sha256}`, defaulting to `sha512` (`$6$`), which is what current JUNOS writes when `set system login password format sha512` is configured; `--variant sha256` instead produces what `set system login password format sha256` writes (`$5$`). This is a convenience for matching a device's existing style, not a correctness requirement: JUNOS `encrypted-password` accepts any crypt hash you paste regardless of the device's `password format` setting, which only governs what the device itself generates from a plaintext. A `$6$` hash produced here (or by any other tool) works on a device set to `sha256` just as well, and vice versa. `--variant` is only offered where it means something: it appears on `--encrypt`, and is silently ignored if given alongside `--check` or `--decrypt`, since neither of those picks a format to produce.
182+
183+
```bash
184+
network-secret juniper-encrypted-password --encrypt 'lab123'
185+
# $6$0W8cbt0LJgH4mTP/$sXjncyJQo/Hb0FLhvkcBota5gC6av4gUv2Vq2WmbHfMU1Q1mp47yqi5Oti8dNc9VPdjZaVRXOAn7AvqpwcCA/0
186+
187+
network-secret juniper-encrypted-password --variant sha256 --encrypt 'lab123'
188+
# $5$wzkCmB9LIq8jselM$oTZKwaa/N7jImALIYZDkZ0inUPBIUGvRmXa1PUbKas5
189+
190+
network-secret juniper-encrypted-password --variant sha512 --encrypt 'lab123'
191+
# $6$.ZENYvk9UOOJ.jkp$Oyxb6dC0wSym6yjjVbMcifgEABK4xhGRqTbF1WqzJtizGMJymEvfspjhomkvwS4ZwUNitqRWY4/A0QV7dgM7Q/
192+
```
193+
194+
Rounds are bounded to 1000-100000 (`MIN_ROUNDS`/`MAX_ROUNDS` in `network_secret/juniper_encrypted_password.py`). Drepper's SHA-crypt spec permits a `rounds=N$` field up to 999,999,999, read from the value being checked, and JUNOS itself never emits anything but its 5000 default; without this bound a pasted hash could force arbitrarily expensive work and hang the tool, the same reasoning behind `juniper8`'s PBKDF2 iteration bound and the Nokia `$2y$` format's bcrypt cost bound.
195+
148196
### Nokia SR OS custom-hash (shared-key)
149197

150198
The shared key is resolved in this order: `-k`/`--key` flag, then the `SROS_CUSTOM_HASH_KEY` environment variable, then an interactive no-echo prompt. Keys must be exactly 16, 24, or 32 characters.

‎network_secret/__init__.py‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
"""network-secret: encode, decode, and check network device secrets.
22
33
Supported formats (one module each):
4-
juniper9 - Juniper/HPE $9$ reversible obfuscation (keyless)
5-
juniper8 - Juniper/HPE $8$ AES-256-GCM (master-password keyed)
6-
nokia_sros_custom_hash - Nokia SR OS custom-hash AES-ECB (shared-key)
7-
nokia_sros_password - Nokia SR OS $2y$ bcrypt password hash (one-way)
8-
cisco_type6 - Cisco IOS type 6 AES + HMAC (master-key keyed)
9-
cisco_type7 - Cisco IOS type 7 XOR obfuscation (keyless)
10-
cisco_type8 - Cisco IOS type 8 PBKDF2-SHA256 hash (one-way)
11-
cisco_type9 - Cisco IOS type 9 scrypt hash (one-way)
4+
juniper9 - Juniper/HPE $9$ reversible obfuscation (keyless)
5+
juniper8 - Juniper/HPE $8$ AES-256-GCM (master-password keyed)
6+
juniper_encrypted_password - Juniper/HPE $5$/$6$ SHA-crypt password hash (one-way)
7+
nokia_sros_custom_hash - Nokia SR OS custom-hash AES-ECB (shared-key)
8+
nokia_sros_password - Nokia SR OS $2y$ bcrypt password hash (one-way)
9+
cisco_type6 - Cisco IOS type 6 AES + HMAC (master-key keyed)
10+
cisco_type7 - Cisco IOS type 7 XOR obfuscation (keyless)
11+
cisco_type8 - Cisco IOS type 8 PBKDF2-SHA256 hash (one-way)
12+
cisco_type9 - Cisco IOS type 9 scrypt hash (one-way)
1213
1314
>>> from network_secret import cisco_type7, juniper9
1415
15-
Cisco type 8, Cisco type 9, and Nokia SR OS password are one-way. Their
16-
encrypt() hashes, their check() verifies a password, and their decrypt()
17-
raises.
16+
Cisco type 8, Cisco type 9, Nokia SR OS password, and Juniper/HPE
17+
encrypted-password are one-way. Their encrypt() hashes, their check()
18+
verifies a password, and their decrypt() raises.
1819
"""
1920

2021
from __future__ import annotations
@@ -28,6 +29,7 @@
2829
cisco_type9,
2930
juniper8,
3031
juniper9,
32+
juniper_encrypted_password,
3133
nokia_sros_custom_hash,
3234
nokia_sros_password,
3335
)
@@ -41,6 +43,7 @@
4143
"cisco_type9",
4244
"juniper8",
4345
"juniper9",
46+
"juniper_encrypted_password",
4447
"nokia_sros_custom_hash",
4548
"nokia_sros_password",
4649
"__version__",

‎network_secret/_sha_crypt.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Shared SHA-crypt implementation for the "$5$"/"$6$" password formats.
2+
3+
"$5$" (sha256-crypt) and "$6$" (sha512-crypt) are the same algorithm
4+
parameterised only by which hash function feeds it, so it is implemented once
5+
here and passed a hash name, exactly as `_cisco_hash.py` shares framing
6+
between Cisco type 8 and type 9 and takes a KDF.
7+
8+
Spec: Ulrich Drepper, "Unix crypt using SHA-256 and SHA-512"
9+
(https://www.akkadia.org/drepper/SHA-crypt.txt). The digest is produced by a
10+
long chain of intermediate hashes (digests A/B/DP/DS feeding a `rounds`-long
11+
loop over digest C), then encoded with a base64 variant unrelated to
12+
standard base64 or to Cisco's variant in `_cisco_hash.py`: 3-byte groups
13+
become 4 characters using little-endian bit order within the group, and the
14+
byte that fills each of the three group positions is picked by a shuffled,
15+
variant-specific permutation rather than taken in sequence. Both the
16+
digest-chain logic and the permutation tables below were verified byte-for-
17+
byte against `openssl passwd -5`/`-6` output, including custom `rounds=`
18+
values (see tests/test_juniper_encrypted_password.py).
19+
20+
This module is internal: it is not exported from network_secret. It performs
21+
no bounds checking on `rounds` - the caller (juniper_encrypted_password.py)
22+
must enforce MIN_ROUNDS/MAX_ROUNDS before calling `hash_password`, since
23+
`rounds` here is a plain multiplier on the amount of hashing work done.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import hashlib
29+
import secrets
30+
31+
ALPHABET = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
32+
SALT_LEN = 16 # characters generated for a fresh salt
33+
34+
_DIGEST_SIZES = {"sha256": 32, "sha512": 64}
35+
36+
# Byte-index permutations feeding _b64_from_24bit(b2, b1, b0, n), taken from
37+
# Drepper's reference sha256-crypt.c / sha512-crypt.c. Each 3-tuple names
38+
# which digest byte fills the high/mid/low position of one 3-byte group.
39+
_SHA256_GROUPS = [
40+
(0, 10, 20), (21, 1, 11), (12, 22, 2), (3, 13, 23), (24, 4, 14),
41+
(15, 25, 5), (6, 16, 26), (27, 7, 17), (18, 28, 8), (9, 19, 29),
42+
]
43+
_SHA256_LAST = (None, 31, 30) # final, partial group: 3 output characters
44+
45+
_SHA512_GROUPS = [
46+
(0, 21, 42), (22, 43, 1), (44, 2, 23), (3, 24, 45), (25, 46, 4),
47+
(47, 5, 26), (6, 27, 48), (28, 49, 7), (50, 8, 29), (9, 30, 51),
48+
(31, 52, 10), (53, 11, 32), (12, 33, 54), (34, 55, 13), (56, 14, 35),
49+
(15, 36, 57), (37, 58, 16), (59, 17, 38), (18, 39, 60), (40, 61, 19),
50+
(62, 20, 41),
51+
]
52+
_SHA512_LAST = (None, None, 63) # final, partial group: 2 output characters
53+
54+
55+
def _b64_from_24bit(b2: int, b1: int, b0: int, n: int) -> str:
56+
"""Encode one 3-byte group (b2 high, b0 low) as n base64 characters.
57+
58+
Unlike standard base64, the 6-bit groups are emitted low-order first.
59+
"""
60+
word = (b2 << 16) | (b1 << 8) | b0
61+
chars = []
62+
for _ in range(n):
63+
chars.append(ALPHABET[word & 0x3F])
64+
word >>= 6
65+
return "".join(chars)
66+
67+
68+
def encode_digest(digest: bytes, hash_name: str) -> str:
69+
"""Encode a raw sha256/sha512 digest with SHA-crypt's base64 variant."""
70+
if hash_name == "sha256":
71+
groups, last, last_n = _SHA256_GROUPS, _SHA256_LAST, 3
72+
elif hash_name == "sha512":
73+
groups, last, last_n = _SHA512_GROUPS, _SHA512_LAST, 2
74+
else:
75+
raise ValueError(f"Unsupported hash function: {hash_name!r}")
76+
out = [
77+
_b64_from_24bit(digest[b2], digest[b1], digest[b0], 4)
78+
for b2, b1, b0 in groups
79+
]
80+
b2, b1, b0 = last
81+
out.append(
82+
_b64_from_24bit(
83+
0 if b2 is None else digest[b2],
84+
0 if b1 is None else digest[b1],
85+
digest[b0],
86+
last_n,
87+
)
88+
)
89+
return "".join(out)
90+
91+
92+
def generate_salt(length: int = SALT_LEN) -> str:
93+
"""Return a fresh `length`-character salt drawn from the crypt alphabet."""
94+
return "".join(secrets.choice(ALPHABET) for _ in range(length))
95+
96+
97+
def hash_password(password: str, salt: str, rounds: int, hash_name: str) -> bytes:
98+
"""Compute the raw SHA-crypt digest for `password`/`salt`/`rounds`.
99+
100+
`rounds` is used exactly as given, with no bounds enforced here - see the
101+
module docstring.
102+
"""
103+
digest_size = _DIGEST_SIZES[hash_name]
104+
pw = password.encode("utf-8")
105+
slt = salt.encode("ascii")
106+
107+
ctx_a = hashlib.new(hash_name)
108+
ctx_a.update(pw)
109+
ctx_a.update(slt)
110+
111+
ctx_b = hashlib.new(hash_name)
112+
ctx_b.update(pw)
113+
ctx_b.update(slt)
114+
ctx_b.update(pw)
115+
digest_b = ctx_b.digest()
116+
117+
plen = len(pw)
118+
remaining = plen
119+
while remaining > digest_size:
120+
ctx_a.update(digest_b)
121+
remaining -= digest_size
122+
ctx_a.update(digest_b[:remaining])
123+
124+
remaining = plen
125+
while remaining > 0:
126+
if remaining & 1:
127+
ctx_a.update(digest_b)
128+
else:
129+
ctx_a.update(pw)
130+
remaining >>= 1
131+
132+
digest_a = ctx_a.digest()
133+
134+
ctx_dp = hashlib.new(hash_name)
135+
for _ in range(plen):
136+
ctx_dp.update(pw)
137+
digest_dp = ctx_dp.digest()
138+
139+
p_seq = bytearray()
140+
remaining = plen
141+
while remaining > digest_size:
142+
p_seq += digest_dp
143+
remaining -= digest_size
144+
p_seq += digest_dp[:remaining]
145+
p_bytes = bytes(p_seq)
146+
147+
ctx_ds = hashlib.new(hash_name)
148+
for _ in range(16 + digest_a[0]):
149+
ctx_ds.update(slt)
150+
digest_ds = ctx_ds.digest()
151+
152+
slen = len(slt)
153+
s_seq = bytearray()
154+
remaining = slen
155+
while remaining > digest_size:
156+
s_seq += digest_ds
157+
remaining -= digest_size
158+
s_seq += digest_ds[:remaining]
159+
s_bytes = bytes(s_seq)
160+
161+
digest_c = digest_a
162+
for round_num in range(rounds):
163+
ctx_c = hashlib.new(hash_name)
164+
ctx_c.update(p_bytes if round_num % 2 else digest_c)
165+
if round_num % 3:
166+
ctx_c.update(s_bytes)
167+
if round_num % 7:
168+
ctx_c.update(p_bytes)
169+
ctx_c.update(digest_c if round_num % 2 else p_bytes)
170+
digest_c = ctx_c.digest()
171+
172+
return digest_c

0 commit comments

Comments
 (0)