Python library and CLI to encrypt, decrypt, and compare Juniper $8$ (type 8) passwords
Project description
juniper8-crypt
Encrypt and decrypt Juniper $8$ (type 8) passwords, from the command line or Python.
The $8$ format is genuine authenticated encryption, keyed by the device master password (set system master-password). Unlike the reversible, keyless $9$ substitution cipher, a $8$ secret cannot be recovered without that master password: the same master password is required to both encrypt and decrypt.
The
$8$algorithm is not documented by Juniper. It was reverse-engineered and verified against a real JUNOS 23.2 device (the GCM authentication tag verifies). See Algorithm for the full details.
Run without installing
If you have uv installed, uvx runs the CLI without installing anything:
uvx juniper8-crypt --master 'MySecretMasterPw' --decrypt '$8$aes256-gcm$hmac-sha2-256$100$p8XEvHtxRNE$d/hqRmh5etkBzo7WSdtvjg$7w1eMTYXkz4RdzMF9CAkJQ$qVLunbFwBWwyxln2Vg'
Install
pip install juniper8-crypt
Or with uv:
uv add juniper8-crypt
Command-line usage
Every operation needs the master password. You can supply it three ways, in order of precedence:
--master/-mon the command line,- the
JUNOS_MASTER_PASSWORDenvironment variable, - an interactive no-echo prompt (used when neither of the above is set).
# Decrypt a $8$ value (master on the command line)
juniper8-crypt --master 'MyMaster' --decrypt '$8$aes256-gcm$hmac-sha2-256$100$...'
# Encrypt a plaintext
juniper8-crypt --master 'MyMaster' --encrypt 'LabBgpSecret1'
# Check a $8$ value against a plaintext or another $8$ value
juniper8-crypt --master 'MyMaster' --check '$8$...' 'LabBgpSecret1'
juniper8-crypt --master 'MyMaster' --check '$8$...' '$8$...'
# Master from the environment (keeps it out of shell history / the process list)
export JUNOS_MASTER_PASSWORD='MyMaster'
juniper8-crypt --decrypt '$8$aes256-gcm$hmac-sha2-256$100$...'
# Master from an interactive prompt (nothing on the command line or in the env)
juniper8-crypt --decrypt '$8$aes256-gcm$hmac-sha2-256$100$...'
# Master password: ‹typed without echo›
Passing
--masteron the command line is convenient but leaks the secret into your shell history and the process list (ps). PreferJUNOS_MASTER_PASSWORDor the prompt for anything sensitive.Always quote
$8$strings with single quotes - the shell expands$8as a positional parameter otherwise.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success (or --check matched) |
| 1 | --check mismatched |
| 2 | Invalid input (malformed value, wrong master, etc.) |
Example output
$ juniper8-crypt --master 'a3f8d9e112c04b7af1c3e8b92d057a4e' \
--decrypt '$8$aes256-gcm$hmac-sha2-256$100$p8XEvHtxRNE$d/hqRmh5etkBzo7WSdtvjg$7w1eMTYXkz4RdzMF9CAkJQ$qVLunbFwBWwyxln2Vg'
LabBgpSecret1
$ juniper8-crypt --master 'a3f8d9e112c04b7af1c3e8b92d057a4e' --encrypt 'LabBgpSecret1'
$8$aes256-gcm$hmac-sha2-256$100$wh8cAoBCbnY$hON9pWdcoFECAJYdqwr3+A$IyYbHprOWFigR4titT+CxA$NWh8D/XOgwafCuK6TQ
$ juniper8-crypt --master 'a3f8d9e112c04b7af1c3e8b92d057a4e' \
--check '$8$aes256-gcm$hmac-sha2-256$100$p8XEvHtxRNE$d/hqRmh5etkBzo7WSdtvjg$7w1eMTYXkz4RdzMF9CAkJQ$qVLunbFwBWwyxln2Vg' 'LabBgpSecret1'
Value 1 : 'LabBgpSecret1'
Value 2 : 'LabBgpSecret1'
Match : YES
--encrypt output varies on every run: a fresh random salt and IV are generated each time, so the same plaintext produces a different $8$ string. They all decrypt back to the same plaintext with the same master password.
Python API
from juniper8_crypt import decrypt, encrypt, check
master = "a3f8d9e112c04b7af1c3e8b92d057a4e"
# Decrypt
plain = decrypt("$8$aes256-gcm$hmac-sha2-256$100$p8XEvHtxRNE$d/hqRmh5etkBzo7WSdtvjg$7w1eMTYXkz4RdzMF9CAkJQ$qVLunbFwBWwyxln2Vg", master)
# 'LabBgpSecret1'
# Encrypt (non-deterministic)
ciphertext = encrypt("LabBgpSecret1", master)
# '$8$aes256-gcm$hmac-sha2-256$100$...' (a fresh value each call)
# Compare a $8$ value against a plaintext
plain_a, plain_b, match = check(ciphertext, "LabBgpSecret1", master)
assert match is True
# Compare two $8$ values
plain_a, plain_b, match = check(ciphertext, encrypt("LabBgpSecret1", master), master)
assert match is True
Error handling
decrypt() raises ValueError for malformed inputs (missing $8$ prefix, wrong field count, unsupported algorithm, invalid base64) and for authentication failure (wrong master password, or a value not produced by this scheme):
from juniper8_crypt import decrypt
try:
decrypt("$8$...", "wrong-master")
except ValueError as e:
print(f"bad input: {e}")
Tests
git clone https://github.com/antoinekh/juniper8-crypt
cd juniper8-crypt
uv run pytest -v
Algorithm
$8$ is the JUNOS "type 8" format used for secrets the device must be able to recover in cleartext (BGP/IS-IS authentication keys, RADIUS secrets, etc.) once a master password is configured. It is real authenticated encryption: PBKDF2 stretches the master password into an AES key, and AES-256-GCM encrypts the secret and authenticates it with a tag.
String layout
$8$<crypt-algo>$<hash-algo>$<iterations>$<salt>$<iv>$<tag>$<ciphertext>
| Field | Example | Meaning |
|---|---|---|
crypt-algo |
aes256-gcm |
Cipher. Only AES-256-GCM is currently emitted. |
hash-algo |
hmac-sha2-256 |
PBKDF2 PRF (HMAC-SHA-256). |
iterations |
100 |
PBKDF2 iteration count (default 100, range 10-10000). |
salt |
p8XEvHtxRNE |
8 random bytes, the PBKDF2 salt. |
iv |
d/hqRmh5e... |
16 bytes; the GCM nonce is the first 12. |
tag |
7w1eMTYX... |
16-byte GCM authentication tag. |
ciphertext |
qVLunbFw... |
The encrypted secret (same length as the plaintext). |
Every binary field is standard base64 (RFC 4648), with the = padding stripped.
Building blocks
1. Key derivation. The master password (as UTF-8 bytes) is stretched with PBKDF2-HMAC-SHA256 over the salt for iterations rounds, producing a 32-byte AES-256 key:
key = PBKDF2HMAC(SHA256(), length=32, salt=salt, iterations=iterations).derive(master.encode())
2. Encryption. AES-256-GCM encrypts the plaintext with no additional authenticated data (AAD), producing the ciphertext and a 16-byte tag:
nonce = iv[:12] # only the first 12 IV bytes are used
sealed = AESGCM(key).encrypt(nonce, plaintext, None)
ciphertext, tag = sealed[:-16], sealed[-16:]
3. The IV gotcha. This is the detail that defeats naive implementations. The iv field decodes to 16 bytes, but JUNOS uses only the first 12 as the GCM nonce (a standard 96-bit nonce); the trailing 4 bytes are random padding that is stored but never used. Feeding all 16 bytes to AES-GCM as the nonce produces a different counter stream and the tag never verifies.
Decryption
- Split the
$8$string and base64-decodesalt,iv,tag,ciphertext. - Re-derive the key with PBKDF2 from the master password and
salt. - AES-256-GCM-decrypt
ciphertextwith nonceiv[:12], verifyingtag. A wrong master password (or any tampering) fails tag verification.
Security note
$8$ is real encryption, but its strength rests entirely on the master password and the (low) default PBKDF2 iteration count of 100. Treat the master password as a high-value secret; anyone holding it can decrypt every $8$ value in a config.
Credits
The $8$ format is undocumented and, as far as I could find, had no public decoder. It was reverse-engineered with AI help: Claude ran the known-plaintext search, spotted that AES-GCM's ciphertext is independent of the tag and AAD (which made the search tractable), and identified the iv[:12] nonce quirk that defeats naive implementations. The decisive input was a device transcript with a known plaintext.
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 juniper8_crypt-0.1.0.tar.gz.
File metadata
- Download URL: juniper8_crypt-0.1.0.tar.gz
- Upload date:
- Size: 267.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 |
f296482c9c26a309eb1c29f9a773c1e028d0b5a3be2ad873c82fb69594b2ddbb
|
|
| MD5 |
765f67a8eb8720ad7b107e33951ff9ff
|
|
| BLAKE2b-256 |
d06daec88fa007f52ed955a9f71bf20f226b8af3b5f7608ff19c1cc8d9031c30
|
Provenance
The following attestation bundles were made for juniper8_crypt-0.1.0.tar.gz:
Publisher:
publish.yml on antoinekh/juniper8-crypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juniper8_crypt-0.1.0.tar.gz -
Subject digest:
f296482c9c26a309eb1c29f9a773c1e028d0b5a3be2ad873c82fb69594b2ddbb - Sigstore transparency entry: 1838482444
- Sigstore integration time:
-
Permalink:
antoinekh/juniper8-crypt@ae41b9519467b1cd76c674dfaadb9502e2946a87 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/antoinekh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ae41b9519467b1cd76c674dfaadb9502e2946a87 -
Trigger Event:
release
-
Statement type:
File details
Details for the file juniper8_crypt-0.1.0-py3-none-any.whl.
File metadata
- Download URL: juniper8_crypt-0.1.0-py3-none-any.whl
- Upload date:
- Size: 9.8 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 |
bb4c4ab5d69c7e538dcebb8d111058eef8209762e0877d05252aa915786b185c
|
|
| MD5 |
c4eb16365ad2c1359b5cc7a8ba427517
|
|
| BLAKE2b-256 |
00225d9c8f86eceabb17802ecfdb8995388fe2506a3e75d160ee23a07a5ebd20
|
Provenance
The following attestation bundles were made for juniper8_crypt-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on antoinekh/juniper8-crypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
juniper8_crypt-0.1.0-py3-none-any.whl -
Subject digest:
bb4c4ab5d69c7e538dcebb8d111058eef8209762e0877d05252aa915786b185c - Sigstore transparency entry: 1838482856
- Sigstore integration time:
-
Permalink:
antoinekh/juniper8-crypt@ae41b9519467b1cd76c674dfaadb9502e2946a87 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/antoinekh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@ae41b9519467b1cd76c674dfaadb9502e2946a87 -
Trigger Event:
release
-
Statement type: