Python library and CLI to encrypt, decrypt, and compare Juniper $9$ passwords
Project description
juniper9-crypt
Encrypt and decrypt Juniper $9$ reversible passwords, from the command line or Python.
The $9$ algorithm is a proprietary Juniper substitution cipher. It is keyless and device-independent: a password encrypted on one Juniper device can be decrypted on any other, with no node-specific secret involved. The algorithm and its character set are publicly documented.
$9$is a substitution cipher, not real cryptography. Treat it as obfuscation, not protection. Anyone with this library (or the source of any Juniper device) can recover the plaintext.
Run without installing
If you have uv installed, uvx runs the CLI without installing anything:
uvx juniper9-crypt --decrypt '$9$FNkC3/t1IcevLuOWx'
Don't have
uvyet? Get it. It's the best thing to happen to Python tooling in years.
Install
pip install juniper9-crypt
Or with uv:
uv add juniper9-crypt
Command-line usage
# Decrypt a $9$ value
juniper9-crypt --decrypt '$9$FNkC3/t1IcevLuOWx'
# Encrypt a plaintext
juniper9-crypt --encrypt 'mysecret'
# Check a $9$ value against a plaintext or another $9$ value
juniper9-crypt --check '$9$FNkC3/t1IcevLuOWx' 'hello'
juniper9-crypt --check '$9$FNkC3/t1IcevLuOWx' '$9$o1aGiPfz/Cuk.tO'
Always quote
$9$strings with single quotes - the shell expands$9as a positional parameter otherwise.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success (or --check matched) |
| 1 | --check mismatched |
| 2 | Invalid input (decrypt error, etc.) |
Example output
$ juniper9-crypt --decrypt '$9$FNkC3/t1IcevLuOWx'
hello
$ juniper9-crypt --encrypt 'hello'
$9$o1aGiPfz/Cuk.tO
$ juniper9-crypt --check '$9$FNkC3/t1IcevLuOWx' 'hello'
Value 1 : 'hello'
Value 2 : 'hello'
Match : YES
--encrypt output varies on every run: the algorithm inserts random filler characters, so the same plaintext produces a different ciphertext each time. They all decrypt back to the same plaintext.
Python API
from juniper9_crypt import decrypt, encrypt, check
# Decrypt
plain = decrypt("$9$FNkC3/t1IcevLuOWx")
# 'hello'
# Encrypt (non-deterministic)
ciphertext = encrypt("hello")
# '$9$o1aGiPfz/Cuk.tO' (or any other valid $9$ form)
# Compare a $9$ value against a plaintext
plain_a, plain_b, match = check("$9$FNkC3/t1IcevLuOWx", "hello")
assert match is True
# Compare two $9$ values
plain_a, plain_b, match = check(
"$9$FNkC3/t1IcevLuOWx",
encrypt("hello"),
)
assert match is True
Error handling
decrypt() raises ValueError for malformed inputs (missing $9$ prefix, characters outside the alphabet, truncated ciphertext). encrypt() raises ValueError for plaintext containing characters outside Latin-1, which the cipher cannot represent:
from juniper9_crypt import decrypt
try:
decrypt("not-a-juniper-string")
except ValueError as e:
print(f"bad input: {e}")
Tests
git clone https://github.com/antoinekh/juniper9-crypt
cd juniper9-crypt
uv run pytest -v
Credits
I'm not nearly smart enough to have reverse-engineered Juniper's $9$ cipher on my own. This package is a small refactor on top of work done by people who actually figured it out, with packaging, tests, and the Python API cleanup done by me with help from Claude.
Crypt::Juniper- original Perl module by Kevin Brintnall (the real reverse-engineering work)junosdecode- Python 2 port by Matt Hite (where the Python implementation comes from)- This package - Python 3 port, type hints,
encrypt()fix,check()API, CLI, tests, and PyPI packaging
Algorithm
$9$ is a position-based substitution cipher with three moving parts: a fixed 65-character alphabet split into families, a fixed weight table per output position, and a chain of "gaps" between successive alphabet positions.
Building blocks
1. The alphabet. 65 characters split into four ordered families:
FAMILY = [
"QzF3n6/9CAtpu0O", # family 0 (15 chars)
"B1IREhcSyrleKvMW8LXx", # family 1 (20 chars)
"7N-dVbwsY2g4oaJZGUDj", # family 2 (20 chars)
"iHkq.mPf5T", # family 3 (10 chars)
]
ALPHA = "".join(FAMILY) # 65 chars total
Each character has a fixed index 0-64 in ALPHA. The family a character belongs to controls how much "filler" is inserted after it (see step 4).
2. The weight table. A cycle of 7 weight vectors, one per output byte position:
ENCODING = [
[1, 4, 32], # byte 0
[1, 16, 32], # byte 1
[1, 8, 32], # byte 2
[1, 64], # byte 3
[1, 32], # byte 4
[1, 4, 16, 128], # byte 5
[1, 32, 64], # byte 6
]
The weights for byte i are ENCODING[i % 7]. Their length (2-4) tells you how many ciphertext characters encode that byte. The weights are bases in a mixed-radix number system: an output byte b is decomposed as b = g0*w0 + g1*w1 + ... where each gk is a small "gap" value.
3. The gap. The cipher never stores absolute positions - only gaps between consecutive characters:
gap(c1, c2) = (NUM[c2] - NUM[c1]) % 65 - 1
So a gap of 0 means "the next character in ALPHA", a gap of 1 means "skip one", etc. Gaps are taken modulo 65, so the alphabet wraps.
Encryption
To encrypt a plaintext like "hi":
- Pick a random start character from
ALPHA. Call its. Output:$9$s. - Insert filler. Look up
EXTRA[s](a value 0-3 depending on which familysbelongs to). Append that many random characters fromALPHA. This filler is decorative - it's discarded on decrypt. It exists purely to randomize the visual appearance of the output. - For each plaintext byte (in order):
- Look up the weights
w = ENCODING[i % 7]for this position. - Decompose the byte's value into gaps using the weights:
gap_last = byte // w_last,remainder = byte % w_last- Repeat down through the weights.
- For each gap, advance from the previous output character by
gap + 1positions inALPHA(mod 65) to find the next output character. - Append those characters to the output.
- Look up the weights
Because step 1 is random, the same plaintext produces a different ciphertext every call. But because the math is fully reversible, all those ciphertexts decrypt back to the same plaintext.
Decryption
- Strip
$9$and read the first characters. - Skip
EXTRA[s]filler characters. - Walk the rest in chunks, sized by
ENCODING[i % 7]for each output bytei:- For each character in the chunk, compute the gap from the previous character.
- Multiply each gap by its corresponding weight and sum:
byte = g0*w0 + g1*w1 + ... - Take
byte % 256and emit it as the plaintext character.
A worked micro-example
Encrypting the single byte 'h' (ASCII 104) at byte position 0, starting from previous character Q (index 0 in ALPHA):
- Weights:
[1, 4, 32]- the byte is decomposed asg2*32 + g1*4 + g0*1. - Decompose:
104 = 3*32 + 2*4 + 0*1- so gaps are[g0=0, g1=2, g2=3]. - Walk forward in
ALPHA, advancinggap + 1positions each step:- From
Q(index 0), advance0+1 = 1- index 1 -z - From
z(index 1), advance2+1 = 3- index 4 -n - From
n(index 4), advance3+1 = 4- index 8 -C
- From
- The three output characters for the byte
'h'areznC.
On decryption, the chain Q - z - n - C is read back: gaps 0, 2, 3, recombined as 0*1 + 2*4 + 3*32 = 104 = 'h'.
Why it's weak
There is no key. The alphabet, families, and weight tables are constants baked into every Juniper device and every implementation (including this one). Anyone with the ciphertext can recover the plaintext using only public information. $9$ is obfuscation, not encryption - it stops shoulder-surfing in a config dump, nothing more.
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file juniper9_crypt-0.2.0.tar.gz.
File metadata
- Download URL: juniper9_crypt-0.2.0.tar.gz
- Upload date:
- Size: 11.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c00df80223086647874dadec93176a4f079d8cef9ef3affa0c2b9ab3c829a589
|
|
| MD5 |
e936b50efb745d149c1caec8e5f2fbda
|
|
| BLAKE2b-256 |
996b2b6304a46df9ff0918fd68213b657109b9a5f5a2611596a20d5068bcc66f
|
Provenance
The following attestation bundles were made for juniper9_crypt-0.2.0.tar.gz:
Publisher:
publish.yml on antoinekh/juniper9-crypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juniper9_crypt-0.2.0.tar.gz -
Subject digest:
c00df80223086647874dadec93176a4f079d8cef9ef3affa0c2b9ab3c829a589 - Sigstore transparency entry: 1769436262
- Sigstore integration time:
-
Permalink:
antoinekh/juniper9-crypt@cefbc5da29570de9ef73cd7f44d3db773b94c6db -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/antoinekh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cefbc5da29570de9ef73cd7f44d3db773b94c6db -
Trigger Event:
release
-
Statement type:
File details
Details for the file juniper9_crypt-0.2.0-py3-none-any.whl.
File metadata
- Download URL: juniper9_crypt-0.2.0-py3-none-any.whl
- Upload date:
- Size: 8.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c93ce5ee0297880e3133d892e7895ff37b9d1712b8751cb86cc8f964ffa48748
|
|
| MD5 |
022ab93e0e5b944d2c2f99b403da3fb9
|
|
| BLAKE2b-256 |
8abd720ae456275a7b2feaaf9238042c100fc074344a35c3e2e3fa7e6fcd1b66
|
Provenance
The following attestation bundles were made for juniper9_crypt-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on antoinekh/juniper9-crypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juniper9_crypt-0.2.0-py3-none-any.whl -
Subject digest:
c93ce5ee0297880e3133d892e7895ff37b9d1712b8751cb86cc8f964ffa48748 - Sigstore transparency entry: 1769437146
- Sigstore integration time:
-
Permalink:
antoinekh/juniper9-crypt@cefbc5da29570de9ef73cd7f44d3db773b94c6db -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/antoinekh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cefbc5da29570de9ef73cd7f44d3db773b94c6db -
Trigger Event:
release
-
Statement type: