Skip to main content

Python library and CLI to encrypt, decrypt, and compare Juniper $8$ (type 8) passwords

Project description

juniper8-crypt

tests PyPI Python versions License: MIT

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:

  1. --master/-m on the command line,
  2. the JUNOS_MASTER_PASSWORD environment variable,
  3. 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 --master on the command line is convenient but leaks the secret into your shell history and the process list (ps). Prefer JUNOS_MASTER_PASSWORD or the prompt for anything sensitive.

Always quote $8$ strings with single quotes - the shell expands $8 as 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

Juniper $8$ encryption overview

$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

  1. Split the $8$ string and base64-decode salt, iv, tag, ciphertext.
  2. Re-derive the key with PBKDF2 from the master password and salt.
  3. AES-256-GCM-decrypt ciphertext with nonce iv[:12], verifying tag. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

juniper8_crypt-0.1.0.tar.gz (267.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

juniper8_crypt-0.1.0-py3-none-any.whl (9.8 kB view details)

Uploaded Python 3

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

Hashes for juniper8_crypt-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f296482c9c26a309eb1c29f9a773c1e028d0b5a3be2ad873c82fb69594b2ddbb
MD5 765f67a8eb8720ad7b107e33951ff9ff
BLAKE2b-256 d06daec88fa007f52ed955a9f71bf20f226b8af3b5f7608ff19c1cc8d9031c30

See more details on using hashes here.

Provenance

The following attestation bundles were made for juniper8_crypt-0.1.0.tar.gz:

Publisher: publish.yml on antoinekh/juniper8-crypt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for juniper8_crypt-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bb4c4ab5d69c7e538dcebb8d111058eef8209762e0877d05252aa915786b185c
MD5 c4eb16365ad2c1359b5cc7a8ba427517
BLAKE2b-256 00225d9c8f86eceabb17802ecfdb8995388fe2506a3e75d160ee23a07a5ebd20

See more details on using hashes here.

Provenance

The following attestation bundles were made for juniper8_crypt-0.1.0-py3-none-any.whl:

Publisher: publish.yml on antoinekh/juniper8-crypt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page