Skip to content

Add JUNOS encrypted-password ($5$ and $6$ SHA-crypt) support - #8

Merged
antoinekh merged 16 commits into
masterfrom
juniper-encrypted-password
Aug 20, 2026
Merged

antoinekh merged 16 commits into
masterfrom
juniper-encrypted-password

Conversation

@antoinekh

Copy link
Copy Markdown
Owner

Adds JUNOS encrypted-password as a ninth format, in the Python package, the CLI and the website.

JUNOS stores local user passwords as standard Unix SHA-crypt, written in config as:

encrypted-password "$5$itHMToxg$IAeDKDuWsSCoL2W7fognCW8cyNj4YE9ZhDvCoWFC5y/"; ## SECRET-DATA

Two variants are covered: $5$ (sha256-crypt) and $6$ (sha512-crypt), both Ulrich Drepper's SHA-crypt specification. $1$ (md5crypt), which older JUNOS wrote, is deliberately out of scope and is rejected by name rather than failing as a generic parse error.

It is one-way, following the contract Cisco type 8/9 and the Nokia $2y$ format established: encrypt hashes, check verifies a candidate password, decrypt raises. On the site it gets Hash and Verify tabs and a One-way badge, and joins the existing Juniper/HPE menu.

No new dependency, on either side

Python 3.13 removed the stdlib crypt module, and passlib imports it so it breaks there too. Web Crypto has no crypt primitive. Both implementations are therefore written from the specification, using only hashlib and Web Crypto for the raw hashes.

That is a different risk profile from previous formats, so the verification was correspondingly heavier. See below.

Verification against independent implementations

The algorithms are checked against two other implementations of the same spec, not only against each other:

  • openssl passwd: 22 boundary cases matched byte for byte, covering password lengths at 31, 32 (the sha256 digest length), 33, 63, 64 (the block size), 65, 127 and 128 bytes, plus multi-byte UTF-8, CJK and emoji passwords, for both variants.
  • glibc, via a Python 3.12 crypt build: used for the empty-password case, which openssl passwd refuses to generate at all. It matches.
  • 33 adversarial inputs were fed to both implementations and compared, including truncated hashes, wrong-length digests, out-of-alphabet characters, empty and over-long salts, and malformed rounds fields. Both produce identical accept/reject behaviour and identical error text on all but one, where a 400-digit rounds value yields a slightly different message while both still reject.

Rounds are bounded, deliberately

$5$ and $6$ accept rounds=N inside the salt, and the spec permits N up to 999999999, read from the value being checked. Unbounded, a pasted hash could force minutes to hours of hashing and freeze a browser tab. Rounds are clamped to 1000-100000 before any hashing, mirroring how juniper8 bounds its iteration count and the Nokia $2y$ format bounds its bcrypt cost. Measured: the JUNOS default of 5000 rounds is 0.003s, and the 100000 ceiling is 0.055s for $5$ and 0.07s for $6$.

Two deliberate divergences, both documented in the code

A non-canonical rounds=007000 is accepted but never emitted. check echoes back the value you gave it with its digits unchanged, while encrypt always writes the canonical form. This matches openssl, which likewise tolerates leading zeros on input and normalises its own output.

An over-long salt is rejected rather than truncated. The spec silently truncates a salt beyond 16 characters. This module refuses it, because a real device can never emit one, and silently hashing against an untruncated salt would diverge from what the device computed. The reasoning is recorded in _validate_salt's docstring.

Also in here

--list column widths are now derived from the registry rather than hardcoded. The new id is 26 characters and overflowed the 24-character column, breaking alignment. An identical defect was fixed once before by widening the number; deriving the widths means it cannot recur.

Verification summary

245 Python tests, 220 web tests, npm run check clean at zero warnings, npm run build succeeds. Every known-answer vector passes through the CLI, including a value pasted with its surrounding quotes, trailing semicolon and ## SECRET-DATA marker intact.

Note that the leading encrypted-password keyword is not stripped; only quotes, a trailing semicolon and the marker are. The README and both modules' docstrings describe that accurately.

@antoinekh

Copy link
Copy Markdown
Owner Author

Added the variant option, so the branch is now 15 commits.

What changed

JUNOS writes $5$ or $6$ depending on set system login password format, and this repo's owner has devices doing both. Hashing here always produced $6$, with no way to ask for $5$ except by hand-supplying a full salt string, which the CLI could not do at all.

  • Python: encrypt(plaintext, salt=None, variant=None), with VARIANTS = ("sha512", "sha256").
  • CLI: --variant {sha512,sha256}, defaulting to sha512.
  • Website: a selector, shown only in Hash mode.

The default is unchanged.

Kept generic rather than special-cased

Cipher gained a variants field in both network_secret/types.py and web/src/lib/ciphers/types.ts. The CLI offers --variant for any format declaring variants, and Converter.svelte renders whatever it finds. Neither knows what JUNOS is: Converter.svelte contains zero references to JUNOS, $5$, $6$, sha256 or sha512.

This follows how env_var was added when Cisco type 6 needed its own environment variable, rather than branching on a cipher id.

Worth being clear about

This is a convenience, not a correctness fix. A $6$ hash from this tool has been tested on a real device configured for sha256 and was accepted: JUNOS encrypted-password takes any crypt hash you paste, and the password format setting only governs what the device generates from a plaintext. The documentation says so explicitly, so nobody concludes their existing $6$ hashes are wrong.

Verification

encrypt("lab123", salt="$5$itHMToxg", variant="sha256") reproduces a real device value byte for byte in both languages, cross-checked against openssl passwd -5. A conflicting salt prefix and variant raises rather than silently picking a winner.

261 Python tests on 3.11, 3.12, 3.13 and 3.14 (types.py is a shared contract, so all four were run), 230 web tests, npm run check clean, npm run build succeeds, 0 npm advisories.

The selector's rendering is the one thing untested here, since this environment has no browser and adding a component-testing library would have meant a new dependency.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Deploying network-secret with  Cloudflare Pages  Cloudflare Pages

Latest commit: 22f0c7c
Status:⚡️  Build in progress...

View logs

@antoinekh
antoinekh merged commit 89294d2 into master Aug 20, 2026
5 of 6 checks passed
@antoinekh
antoinekh deleted the juniper-encrypted-password branch August 20, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant