network-secret 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antoine Keranflec'h
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: network-secret
3
+ Version: 0.1.0
4
+ Summary: Encode, decode, and check network device secrets: Juniper $9$, Juniper $8$, and Nokia SR OS custom-hash
5
+ Project-URL: Homepage, https://github.com/antoinekh/network-secret
6
+ Project-URL: Repository, https://github.com/antoinekh/network-secret
7
+ Project-URL: Issues, https://github.com/antoinekh/network-secret/issues
8
+ Author-email: Antoine Keranflec'h <antoine.keranflech@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Antoine Keranflec'h
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: $8$,$9$,crypt,custom-hash,decrypt,juniper,junos,network,nokia,password,sros
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Environment :: Console
34
+ Classifier: Intended Audience :: System Administrators
35
+ Classifier: Intended Audience :: Telecommunications Industry
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Security :: Cryptography
43
+ Classifier: Topic :: System :: Networking
44
+ Requires-Python: >=3.11
45
+ Requires-Dist: cryptography>=42.0
46
+ Description-Content-Type: text/markdown
47
+
48
+ # network-secret
49
+
50
+ Encode, decode, and check network device secrets for Juniper JunOS and Nokia SR OS, from the command line or Python. `network-secret` is a unified successor to `juniper8-crypt` and `juniper9-crypt`: it covers all three formats in a single package with a single CLI.
51
+
52
+ > **Prefer a browser?** Encode and decode all three formats at **[network-secret-website.pages.dev](https://network-secret-website.pages.dev/)**. It runs the same algorithms fully client-side - nothing you type is ever sent to a server.
53
+
54
+ ## Supported formats
55
+
56
+ | Format | CLI subcommand | Python module | Description |
57
+ |--------|---------------|---------------|-------------|
58
+ | `$9$` | `juniper9` | `network_secret.juniper9` | Juniper reversible obfuscation - keyless |
59
+ | `$8$` | `juniper8` | `network_secret.juniper8` | Juniper AES-256-GCM - keyed by master password |
60
+ | Nokia custom-hash | `nokia-sros-custom-hash` | `network_secret.nokia_sros_custom_hash` | Nokia SR OS AES-ECB shared-key cipher |
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install network-secret
66
+ ```
67
+
68
+ Or with `uv`:
69
+
70
+ ```bash
71
+ uv add network-secret
72
+ ```
73
+
74
+ ## Python API
75
+
76
+ ```python
77
+ from network_secret import juniper8, juniper9, nokia_sros_custom_hash
78
+
79
+ # Juniper $9$ (keyless)
80
+ cipher9 = juniper9.encrypt("BGPsecret1")
81
+ plain9 = juniper9.decrypt(cipher9)
82
+ # 'BGPsecret1'
83
+
84
+ # Juniper $8$ (master-password keyed)
85
+ master = "MyMasterPassword"
86
+ cipher8 = juniper8.encrypt("BGPsecret1", master)
87
+ plain8 = juniper8.decrypt(cipher8, master)
88
+ # 'BGPsecret1'
89
+ plain_a, plain_b, match = juniper8.check(cipher8, "BGPsecret1", master)
90
+ # match is True
91
+
92
+ # Nokia SR OS custom-hash (16/24/32-character shared key)
93
+ key = "a3f8d9e112c04b7af1c3e8b92d057a4e"
94
+ cipher_nokia = nokia_sros_custom_hash.encrypt("BGPsecret1", key)
95
+ plain_nokia = nokia_sros_custom_hash.decrypt(cipher_nokia, key)
96
+ # 'BGPsecret1'
97
+ plain_a, plain_b, match = nokia_sros_custom_hash.check(cipher_nokia, "BGPsecret1", key)
98
+ # match is True
99
+ ```
100
+
101
+ All three `check()` functions return a `tuple[str, str, bool]`: the two decrypted plaintexts and whether they match.
102
+
103
+ ## Command-line usage
104
+
105
+ ```bash
106
+ # List all supported ciphers
107
+ network-secret --list
108
+
109
+ # Show the version
110
+ network-secret --version
111
+ ```
112
+
113
+ ### Juniper `$9$` (keyless)
114
+
115
+ ```bash
116
+ network-secret juniper9 --encrypt 'BGPsecret1'
117
+ network-secret juniper9 --decrypt '$9$abc...'
118
+ network-secret juniper9 --check '$9$abc...' 'BGPsecret1'
119
+ ```
120
+
121
+ ### Juniper `$8$` (master-password keyed)
122
+
123
+ The master password is resolved in this order: `-m`/`--master` flag, then the `JUNOS_MASTER_PASSWORD` environment variable, then an interactive no-echo prompt.
124
+
125
+ ```bash
126
+ # Master on the command line
127
+ network-secret juniper8 -m 'MyMaster' --encrypt 'BGPsecret1'
128
+ network-secret juniper8 -m 'MyMaster' --decrypt '$8$aes256-gcm$...'
129
+ network-secret juniper8 -m 'MyMaster' --check '$8$aes256-gcm$...' 'BGPsecret1'
130
+
131
+ # Master from the environment (keeps it out of shell history and the process list)
132
+ export JUNOS_MASTER_PASSWORD='MyMaster'
133
+ network-secret juniper8 --decrypt '$8$aes256-gcm$...'
134
+
135
+ # Master from an interactive prompt
136
+ network-secret juniper8 --decrypt '$8$aes256-gcm$...'
137
+ # Master password: <typed without echo>
138
+ ```
139
+
140
+ > Always quote `$8$` and `$9$` strings with single quotes - the shell expands `$8` and `$9` as positional parameters otherwise.
141
+
142
+ ### Nokia SR OS custom-hash (shared-key)
143
+
144
+ 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.
145
+
146
+ ```bash
147
+ # Key on the command line
148
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --encrypt 'BGPsecret1'
149
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --decrypt 'ABC123...'
150
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --check 'ABC123...' 'BGPsecret1'
151
+
152
+ # Key from the environment
153
+ export SROS_CUSTOM_HASH_KEY='a3f8d9e112c04b7af1c3e8b92d057a4e'
154
+ network-secret nokia-sros-custom-hash --decrypt 'ABC123...'
155
+ ```
156
+
157
+ ### Exit codes
158
+
159
+ | Code | Meaning |
160
+ |------|---------|
161
+ | 0 | Success (or `--check` matched) |
162
+ | 1 | `--check` mismatched |
163
+ | 2 | Invalid input (malformed value, wrong key, etc.) |
164
+
165
+ ## Supersedes
166
+
167
+ `network-secret` supersedes the older single-format packages `juniper8-crypt` and `juniper9-crypt`. It exposes the same algorithms under the same function signatures (`encrypt`, `decrypt`, `check`); migrating is a matter of updating the import path.
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,124 @@
1
+ # network-secret
2
+
3
+ Encode, decode, and check network device secrets for Juniper JunOS and Nokia SR OS, from the command line or Python. `network-secret` is a unified successor to `juniper8-crypt` and `juniper9-crypt`: it covers all three formats in a single package with a single CLI.
4
+
5
+ > **Prefer a browser?** Encode and decode all three formats at **[network-secret-website.pages.dev](https://network-secret-website.pages.dev/)**. It runs the same algorithms fully client-side - nothing you type is ever sent to a server.
6
+
7
+ ## Supported formats
8
+
9
+ | Format | CLI subcommand | Python module | Description |
10
+ |--------|---------------|---------------|-------------|
11
+ | `$9$` | `juniper9` | `network_secret.juniper9` | Juniper reversible obfuscation - keyless |
12
+ | `$8$` | `juniper8` | `network_secret.juniper8` | Juniper AES-256-GCM - keyed by master password |
13
+ | Nokia custom-hash | `nokia-sros-custom-hash` | `network_secret.nokia_sros_custom_hash` | Nokia SR OS AES-ECB shared-key cipher |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install network-secret
19
+ ```
20
+
21
+ Or with `uv`:
22
+
23
+ ```bash
24
+ uv add network-secret
25
+ ```
26
+
27
+ ## Python API
28
+
29
+ ```python
30
+ from network_secret import juniper8, juniper9, nokia_sros_custom_hash
31
+
32
+ # Juniper $9$ (keyless)
33
+ cipher9 = juniper9.encrypt("BGPsecret1")
34
+ plain9 = juniper9.decrypt(cipher9)
35
+ # 'BGPsecret1'
36
+
37
+ # Juniper $8$ (master-password keyed)
38
+ master = "MyMasterPassword"
39
+ cipher8 = juniper8.encrypt("BGPsecret1", master)
40
+ plain8 = juniper8.decrypt(cipher8, master)
41
+ # 'BGPsecret1'
42
+ plain_a, plain_b, match = juniper8.check(cipher8, "BGPsecret1", master)
43
+ # match is True
44
+
45
+ # Nokia SR OS custom-hash (16/24/32-character shared key)
46
+ key = "a3f8d9e112c04b7af1c3e8b92d057a4e"
47
+ cipher_nokia = nokia_sros_custom_hash.encrypt("BGPsecret1", key)
48
+ plain_nokia = nokia_sros_custom_hash.decrypt(cipher_nokia, key)
49
+ # 'BGPsecret1'
50
+ plain_a, plain_b, match = nokia_sros_custom_hash.check(cipher_nokia, "BGPsecret1", key)
51
+ # match is True
52
+ ```
53
+
54
+ All three `check()` functions return a `tuple[str, str, bool]`: the two decrypted plaintexts and whether they match.
55
+
56
+ ## Command-line usage
57
+
58
+ ```bash
59
+ # List all supported ciphers
60
+ network-secret --list
61
+
62
+ # Show the version
63
+ network-secret --version
64
+ ```
65
+
66
+ ### Juniper `$9$` (keyless)
67
+
68
+ ```bash
69
+ network-secret juniper9 --encrypt 'BGPsecret1'
70
+ network-secret juniper9 --decrypt '$9$abc...'
71
+ network-secret juniper9 --check '$9$abc...' 'BGPsecret1'
72
+ ```
73
+
74
+ ### Juniper `$8$` (master-password keyed)
75
+
76
+ The master password is resolved in this order: `-m`/`--master` flag, then the `JUNOS_MASTER_PASSWORD` environment variable, then an interactive no-echo prompt.
77
+
78
+ ```bash
79
+ # Master on the command line
80
+ network-secret juniper8 -m 'MyMaster' --encrypt 'BGPsecret1'
81
+ network-secret juniper8 -m 'MyMaster' --decrypt '$8$aes256-gcm$...'
82
+ network-secret juniper8 -m 'MyMaster' --check '$8$aes256-gcm$...' 'BGPsecret1'
83
+
84
+ # Master from the environment (keeps it out of shell history and the process list)
85
+ export JUNOS_MASTER_PASSWORD='MyMaster'
86
+ network-secret juniper8 --decrypt '$8$aes256-gcm$...'
87
+
88
+ # Master from an interactive prompt
89
+ network-secret juniper8 --decrypt '$8$aes256-gcm$...'
90
+ # Master password: <typed without echo>
91
+ ```
92
+
93
+ > Always quote `$8$` and `$9$` strings with single quotes - the shell expands `$8` and `$9` as positional parameters otherwise.
94
+
95
+ ### Nokia SR OS custom-hash (shared-key)
96
+
97
+ 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.
98
+
99
+ ```bash
100
+ # Key on the command line
101
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --encrypt 'BGPsecret1'
102
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --decrypt 'ABC123...'
103
+ network-secret nokia-sros-custom-hash -k 'a3f8d9e112c04b7af1c3e8b92d057a4e' --check 'ABC123...' 'BGPsecret1'
104
+
105
+ # Key from the environment
106
+ export SROS_CUSTOM_HASH_KEY='a3f8d9e112c04b7af1c3e8b92d057a4e'
107
+ network-secret nokia-sros-custom-hash --decrypt 'ABC123...'
108
+ ```
109
+
110
+ ### Exit codes
111
+
112
+ | Code | Meaning |
113
+ |------|---------|
114
+ | 0 | Success (or `--check` matched) |
115
+ | 1 | `--check` mismatched |
116
+ | 2 | Invalid input (malformed value, wrong key, etc.) |
117
+
118
+ ## Supersedes
119
+
120
+ `network-secret` supersedes the older single-format packages `juniper8-crypt` and `juniper9-crypt`. It exposes the same algorithms under the same function signatures (`encrypt`, `decrypt`, `check`); migrating is a matter of updating the import path.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,24 @@
1
+ """network-secret: encode, decode, and check network device secrets.
2
+
3
+ Supported ciphers (one module each):
4
+ juniper9 - Juniper $9$ reversible obfuscation (keyless)
5
+ juniper8 - Juniper $8$ AES-256-GCM (master-password keyed)
6
+ nokia_sros_custom_hash - Nokia SR OS custom-hash AES-ECB (shared-key)
7
+
8
+ >>> from network_secret import juniper8, juniper9, nokia_sros_custom_hash
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from importlib.metadata import version
14
+
15
+ from . import juniper8, juniper9, nokia_sros_custom_hash
16
+
17
+ __version__ = version("network-secret")
18
+
19
+ __all__ = [
20
+ "juniper8",
21
+ "juniper9",
22
+ "nokia_sros_custom_hash",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,126 @@
1
+ """The network-secret command-line interface.
2
+
3
+ One subcommand per cipher (from the registry). Each subcommand offers
4
+ --encrypt / --decrypt / --check, plus a key option for keyed ciphers. The
5
+ top level offers --list and --version.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import getpass
12
+ import os
13
+ import sys
14
+
15
+ from . import juniper8, nokia_sros_custom_hash
16
+ from .registry import REGISTRY, find
17
+ from .types import Cipher, KeyKind
18
+
19
+ __all__ = ["main"]
20
+
21
+ _PROMPTS = {
22
+ KeyKind.MASTER_PASSWORD: "Master password: ",
23
+ KeyKind.SHARED_KEY: "Shared key: ",
24
+ }
25
+ _ENV_VARS = {
26
+ KeyKind.MASTER_PASSWORD: juniper8.ENV_MASTER,
27
+ KeyKind.SHARED_KEY: nokia_sros_custom_hash.ENV_KEY,
28
+ }
29
+
30
+
31
+ def _build_parser() -> argparse.ArgumentParser:
32
+ from . import __version__
33
+
34
+ parser = argparse.ArgumentParser(
35
+ prog="network-secret",
36
+ description="Encode, decode, and check network device secrets.",
37
+ )
38
+ parser.add_argument(
39
+ "--version", action="version", version=f"%(prog)s {__version__}"
40
+ )
41
+ parser.add_argument(
42
+ "--list",
43
+ action="store_true",
44
+ help="List the supported ciphers and exit",
45
+ )
46
+ sub = parser.add_subparsers(dest="cipher", metavar="CIPHER")
47
+
48
+ for cipher in REGISTRY:
49
+ p = sub.add_parser(cipher.id, help=cipher.name)
50
+ if cipher.key_kind is KeyKind.MASTER_PASSWORD:
51
+ p.add_argument(
52
+ "-m", "--master", metavar="MASTER",
53
+ help=(
54
+ "Master password used to derive the key. If omitted, read "
55
+ f"from {juniper8.ENV_MASTER} or prompted for without echo."
56
+ ),
57
+ )
58
+ elif cipher.key_kind is KeyKind.SHARED_KEY:
59
+ p.add_argument(
60
+ "-k", "--key", metavar="KEY",
61
+ help=(
62
+ "Shared AES key (16/24/32 chars). If omitted, read from "
63
+ f"{nokia_sros_custom_hash.ENV_KEY} or prompted for without echo."
64
+ ),
65
+ )
66
+ group = p.add_mutually_exclusive_group(required=True)
67
+ group.add_argument("--encrypt", metavar="PLAINTEXT", help="Encrypt a plaintext value")
68
+ group.add_argument("--decrypt", metavar="CIPHERTEXT", help="Decrypt a secret value")
69
+ group.add_argument(
70
+ "--check", nargs=2, metavar=("CIPHERTEXT", "VALUE"),
71
+ help="Decrypt and compare. Exit 0 on match, 1 on mismatch.",
72
+ )
73
+ return parser
74
+
75
+
76
+ def _resolve_key(cipher: Cipher, args: argparse.Namespace) -> str:
77
+ """Resolve a key: explicit flag, then env var, then no-echo prompt."""
78
+ flag = args.master if cipher.key_kind is KeyKind.MASTER_PASSWORD else args.key
79
+ if flag is not None:
80
+ return flag
81
+ env = os.environ.get(_ENV_VARS[cipher.key_kind])
82
+ if env is not None:
83
+ return env
84
+ return getpass.getpass(_PROMPTS[cipher.key_kind])
85
+
86
+
87
+ def _print_check(plain_a: str, plain_b: str, match: bool) -> None:
88
+ print(f"Value 1 : {plain_a!r}")
89
+ print(f"Value 2 : {plain_b!r}")
90
+ print(f"Match : {'YES' if match else 'NO'}")
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ parser = _build_parser()
95
+ args = parser.parse_args(argv)
96
+
97
+ if args.list:
98
+ for c in REGISTRY:
99
+ print(f"{c.id:24} {c.vendor:8} {c.name}")
100
+ return 0
101
+
102
+ if args.cipher is None:
103
+ parser.print_help()
104
+ return 0
105
+
106
+ cipher = find(args.cipher)
107
+ assert cipher is not None # argparse only allows registered ids
108
+ key_args: tuple[str, ...] = (_resolve_key(cipher, args),) if cipher.keyed else ()
109
+
110
+ try:
111
+ if args.encrypt is not None:
112
+ print(cipher.encrypt(args.encrypt, *key_args))
113
+ elif args.decrypt is not None:
114
+ print(cipher.decrypt(args.decrypt, *key_args))
115
+ else: # --check
116
+ plain_a, plain_b, match = cipher.check(args.check[0], args.check[1], *key_args)
117
+ _print_check(plain_a, plain_b, match)
118
+ return 0 if match else 1
119
+ except ValueError as e:
120
+ print(f"error: {e}", file=sys.stderr)
121
+ return 2
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ sys.exit(main())
@@ -0,0 +1,170 @@
1
+ """
2
+ Encrypt and decrypt Juniper $8$ (type 8) passwords.
3
+
4
+ Unlike the reversible, keyless $9$ substitution cipher, the $8$ format is
5
+ genuine authenticated encryption keyed by the device master password
6
+ (``set system master-password``). The same master password is required to
7
+ both encrypt and decrypt: without it, $8$ secrets cannot be recovered.
8
+
9
+ Juniper documents the format, but the documentation is incomplete: it omits
10
+ that only the first 12 bytes of the 16-byte iv field are used as the GCM
11
+ nonce, so a by-the-book implementation fails authentication. That missing
12
+ detail was reverse-engineered and verified against a real JUNOS 23.2 device
13
+ (tag authentication passes):
14
+
15
+ $8$<crypt-algo>$<hash-algo>$<iterations>$<salt>$<iv>$<tag>$<ciphertext>
16
+
17
+ * encoding : standard base64 (RFC 4648), no padding, for every binary field.
18
+ * key : PBKDF2-HMAC-SHA256(master_password, salt, iterations) -> 32 bytes.
19
+ * cipher : AES-256-GCM, no additional authenticated data (AAD).
20
+ * nonce : the iv field decodes to 16 bytes, but only the first 12 are used
21
+ as the GCM nonce; the trailing 4 bytes are unused padding.
22
+
23
+ Public API:
24
+ decrypt(ciphertext: str, master_password: str) -> str
25
+ encrypt(plaintext: str, master_password: str) -> str
26
+ check(ciphertext: str, other: str, master_password: str) -> tuple[str, str, bool]
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import base64
32
+ import binascii
33
+ import os
34
+
35
+ from cryptography.exceptions import InvalidTag
36
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
37
+ from cryptography.hazmat.primitives.hashes import SHA256
38
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
39
+
40
+ __all__ = ["decrypt", "encrypt", "check"]
41
+
42
+ MAGIC = "$8$"
43
+
44
+ # Environment variable the CLI reads the master password from when --master
45
+ # is not given on the command line.
46
+ ENV_MASTER = "JUNOS_MASTER_PASSWORD"
47
+
48
+ # Fixed algorithm parameters JUNOS currently emits for type 8.
49
+ CRYPT_ALGO = "aes256-gcm"
50
+ HASH_ALGO = "hmac-sha2-256"
51
+ DEFAULT_ITERATIONS = 100
52
+ # Bounds JUNOS accepts for the type 8 iteration count. Enforced on decrypt so a
53
+ # hostile $8$ value cannot force an arbitrarily expensive PBKDF2 derivation.
54
+ MIN_ITERATIONS = 10
55
+ MAX_ITERATIONS = 10000
56
+ SALT_LEN = 8 # bytes
57
+ IV_LEN = 16 # bytes stored in the string
58
+ NONCE_LEN = 12 # bytes of the IV actually used as the GCM nonce
59
+ KEY_LEN = 32 # bytes, AES-256
60
+ TAG_LEN = 16 # bytes, GCM authentication tag
61
+
62
+
63
+ def _b64encode(data: bytes) -> str:
64
+ return base64.b64encode(data).decode("ascii").rstrip("=")
65
+
66
+
67
+ def _b64decode(text: str) -> bytes:
68
+ try:
69
+ return base64.b64decode(text + "=" * (-len(text) % 4), validate=True)
70
+ except binascii.Error as e:
71
+ raise ValueError(f"Invalid base64 in $8$ value: {text!r}") from e
72
+
73
+
74
+ def _derive_key(master_password: str, salt: bytes, iterations: int) -> bytes:
75
+ kdf = PBKDF2HMAC(
76
+ algorithm=SHA256(), length=KEY_LEN, salt=salt, iterations=iterations
77
+ )
78
+ return kdf.derive(master_password.encode("utf-8"))
79
+
80
+
81
+ def encrypt(plaintext: str, master_password: str) -> str:
82
+ """Encrypt plaintext into a Juniper $8$ value under master_password.
83
+
84
+ Output is non-deterministic: a fresh random salt and IV are generated on
85
+ every call, so the same plaintext produces a different $8$ string each
86
+ time. All of them decrypt back to the same plaintext with the same master
87
+ password.
88
+ """
89
+ salt = os.urandom(SALT_LEN)
90
+ iv = os.urandom(IV_LEN)
91
+ key = _derive_key(master_password, salt, DEFAULT_ITERATIONS)
92
+
93
+ # JUNOS uses only the first 12 IV bytes as the GCM nonce. AESGCM appends
94
+ # the authentication tag to the ciphertext.
95
+ sealed = AESGCM(key).encrypt(iv[:NONCE_LEN], plaintext.encode("utf-8"), None)
96
+ ciphertext, tag = sealed[:-TAG_LEN], sealed[-TAG_LEN:]
97
+
98
+ return MAGIC + "$".join(
99
+ [
100
+ CRYPT_ALGO,
101
+ HASH_ALGO,
102
+ str(DEFAULT_ITERATIONS),
103
+ _b64encode(salt),
104
+ _b64encode(iv),
105
+ _b64encode(tag),
106
+ _b64encode(ciphertext),
107
+ ]
108
+ )
109
+
110
+
111
+ def decrypt(ciphertext: str, master_password: str) -> str:
112
+ """Decrypt a Juniper $8$ value with master_password.
113
+
114
+ Raises ValueError on a malformed $8$ string or on authentication failure
115
+ (wrong master password, or a value not produced by this scheme).
116
+ """
117
+ if not ciphertext.startswith(MAGIC):
118
+ raise ValueError(f"Not a Juniper $8$ string: must start with {MAGIC}")
119
+
120
+ # Leading '$' yields an empty first element; '8' is the type tag.
121
+ parts = ciphertext.split("$")
122
+ if len(parts) != 9:
123
+ raise ValueError("Malformed $8$ value: expected 7 fields after the prefix")
124
+ _, _type, crypt_algo, hash_algo, iters, salt_b, iv_b, tag_b, ct_b = parts
125
+
126
+ if crypt_algo != CRYPT_ALGO:
127
+ raise ValueError(f"Unsupported crypt-algo {crypt_algo!r}: only {CRYPT_ALGO}")
128
+ if hash_algo != HASH_ALGO:
129
+ raise ValueError(f"Unsupported hash-algo {hash_algo!r}: only {HASH_ALGO}")
130
+ if not (iters.isascii() and iters.isdigit()):
131
+ # str.isdigit() is True for non-ASCII digits (e.g. superscript "²")
132
+ # that int() cannot parse; require plain ASCII digits.
133
+ raise ValueError(f"Invalid iteration count: {iters!r}")
134
+ if not MIN_ITERATIONS <= int(iters) <= MAX_ITERATIONS:
135
+ raise ValueError(
136
+ f"Iteration count {iters} out of range "
137
+ f"({MIN_ITERATIONS}-{MAX_ITERATIONS})"
138
+ )
139
+
140
+ salt, iv, tag, ct = (
141
+ _b64decode(salt_b),
142
+ _b64decode(iv_b),
143
+ _b64decode(tag_b),
144
+ _b64decode(ct_b),
145
+ )
146
+ key = _derive_key(master_password, salt, int(iters))
147
+ try:
148
+ plaintext = AESGCM(key).decrypt(iv[:NONCE_LEN], ct + tag, None)
149
+ except InvalidTag as e:
150
+ raise ValueError(
151
+ "Authentication failed: wrong master password, or the value was "
152
+ "not produced by this scheme"
153
+ ) from e
154
+ return plaintext.decode("utf-8")
155
+
156
+
157
+ def check(
158
+ ciphertext: str, other: str, master_password: str
159
+ ) -> tuple[str, str, bool]:
160
+ """Decrypt `ciphertext` and compare to `other`.
161
+
162
+ `other` may be a plaintext, or another $8$ value (auto-detected by the
163
+ $8$ prefix and decrypted with the same master password). Returns
164
+ (plain_a, plain_b, match).
165
+ """
166
+ plain_a = decrypt(ciphertext, master_password)
167
+ plain_b = (
168
+ decrypt(other, master_password) if other.startswith(MAGIC) else other
169
+ )
170
+ return plain_a, plain_b, plain_a == plain_b
@@ -0,0 +1,106 @@
1
+ """
2
+ Encrypt and decrypt Juniper $9$ reversible passwords.
3
+
4
+ The $9$ algorithm is a proprietary Juniper substitution cipher that is
5
+ deterministic and device-independent: the same plaintext can be encrypted
6
+ on any Juniper device and decrypted on any other, with no node-specific
7
+ secret involved.
8
+
9
+ This is a Python implementation of the algorithm. It is based on
10
+ `Crypt::Juniper` (Perl, Kevin Brintnall) and Matt Hite's Python 2 port
11
+ at https://github.com/mhite/junosdecode.
12
+
13
+ Public API:
14
+ decrypt(ciphertext: str) -> str
15
+ encrypt(plaintext: str) -> str
16
+ check(ciphertext: str, other: str) -> tuple[str, str, bool]
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import random
22
+
23
+ __all__ = ["decrypt", "encrypt", "check"]
24
+
25
+ MAGIC = "$9$"
26
+ FAMILY = ["QzF3n6/9CAtpu0O", "B1IREhcSyrleKvMW8LXx", "7N-dVbwsY2g4oaJZGUDj", "iHkq.mPf5T"]
27
+ EXTRA = {c: 3 - i for i, f in enumerate(FAMILY) for c in f}
28
+ ALPHA = "".join(FAMILY)
29
+ NUM = {c: i for i, c in enumerate(ALPHA)}
30
+ ENCODING = [[1, 4, 32], [1, 16, 32], [1, 8, 32], [1, 64], [1, 32], [1, 4, 16, 128], [1, 32, 64]]
31
+
32
+
33
+ def _gap(c1: str, c2: str) -> int:
34
+ return (NUM[c2] - NUM[c1]) % len(ALPHA) - 1
35
+
36
+
37
+ def decrypt(ciphertext: str) -> str:
38
+ """Decrypt a Juniper $9$ ciphertext back to plaintext."""
39
+ if not ciphertext.startswith(MAGIC):
40
+ raise ValueError(f"Not a Juniper $9$ string: must start with {MAGIC}")
41
+ chars = ciphertext[len(MAGIC):]
42
+ if not chars:
43
+ raise ValueError("Empty $9$ payload")
44
+ first = chars[0]
45
+ if first not in NUM:
46
+ raise ValueError(f"Invalid start character {first!r}: not in $9$ alphabet")
47
+ chars = chars[1 + EXTRA[first]:]
48
+ prev = first
49
+ result: list[str] = []
50
+ while chars:
51
+ weights = ENCODING[len(result) % len(ENCODING)]
52
+ nibble = chars[: len(weights)]
53
+ if len(nibble) < len(weights):
54
+ raise ValueError("Truncated $9$ ciphertext: incomplete final character group")
55
+ chars = chars[len(weights):]
56
+ val = 0
57
+ for c, w in zip(nibble, weights):
58
+ if c not in NUM:
59
+ raise ValueError(f"Character {c!r} not in $9$ alphabet: ciphertext may be invalid")
60
+ val += _gap(prev, c) * w
61
+ prev = c
62
+ result.append(chr(val % 256))
63
+ return "".join(result)
64
+
65
+
66
+ def encrypt(plaintext: str) -> str:
67
+ """Encrypt plaintext to a Juniper $9$ ciphertext.
68
+
69
+ Output is non-deterministic: random filler characters mean each call
70
+ returns a different ciphertext for the same plaintext. All of them
71
+ decrypt back to the same plaintext. `random` (not `secrets`) is used
72
+ deliberately, since $9$ is a substitution cipher with no real
73
+ cryptographic strength: a stronger RNG buys nothing.
74
+ """
75
+ start = random.choice(ALPHA)
76
+ result = [MAGIC, start]
77
+ for _ in range(EXTRA[start]):
78
+ result.append(random.choice(ALPHA))
79
+ prev = start
80
+ for i, ch in enumerate(plaintext):
81
+ weights = ENCODING[i % len(ENCODING)]
82
+ val = ord(ch)
83
+ if val > 255:
84
+ raise ValueError(
85
+ f"Character {ch!r} cannot be $9$-encoded: only single-byte (Latin-1) characters are supported"
86
+ )
87
+ gaps: list[int] = []
88
+ for w in reversed(weights):
89
+ gaps.append(val // w)
90
+ val = val % w
91
+ for gap in reversed(gaps):
92
+ c = ALPHA[(NUM[prev] + gap + 1) % len(ALPHA)]
93
+ result.append(c)
94
+ prev = c
95
+ return "".join(result)
96
+
97
+
98
+ def check(ciphertext: str, other: str) -> tuple[str, str, bool]:
99
+ """Decrypt `ciphertext` and compare to `other`.
100
+
101
+ `other` may be a plaintext, or another $9$ value (auto-detected by
102
+ the $9$ prefix). Returns (plain_a, plain_b, match).
103
+ """
104
+ plain_a = decrypt(ciphertext)
105
+ plain_b = decrypt(other) if other.startswith(MAGIC) else other
106
+ return plain_a, plain_b, plain_a == plain_b
@@ -0,0 +1,110 @@
1
+ """Nokia SR OS custom-hash secrets.
2
+
3
+ Configured on SR OS via:
4
+ admin system security hash-control custom-hash algorithm aes256 key "..."
5
+
6
+ It is deterministic AES in ECB mode with PKCS#7 padding, base64-encoded, and
7
+ carried in config as "<base64> custom". Because ECB is deterministic, the same
8
+ plaintext and key produce identical ciphertext on every node sharing the key -
9
+ which is the point: portable, node-independent secrets.
10
+
11
+ The key is the literal characters of the shared-key string (SR OS counts the
12
+ string length, not a decoded byte length). 16 / 24 / 32 characters select
13
+ AES-128 / AES-192 / AES-256.
14
+
15
+ Public API:
16
+ encrypt(plaintext: str, key: str) -> str
17
+ decrypt(ciphertext: str, key: str) -> str
18
+ check(ciphertext: str, other: str, key: str) -> tuple[str, str, bool]
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import binascii
25
+
26
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
27
+ from cryptography.hazmat.primitives.padding import PKCS7
28
+
29
+ __all__ = ["encrypt", "decrypt", "check"]
30
+
31
+ SUFFIX = "custom"
32
+ # Environment variable the CLI reads the shared key from when -k/--key is absent.
33
+ ENV_KEY = "SROS_CUSTOM_HASH_KEY"
34
+ BLOCK = 16 # AES block size in bytes
35
+ # SR OS writes the marker as exactly "<base64> custom": one space before the
36
+ # word. Matching is case-insensitive; surrounding whitespace is trimmed.
37
+ _MARKER = f" {SUFFIX}"
38
+
39
+
40
+ def _has_suffix(value: str) -> bool:
41
+ return value.strip().lower().endswith(_MARKER)
42
+
43
+
44
+ def _strip_suffix(value: str) -> str:
45
+ value = value.strip()
46
+ if _has_suffix(value):
47
+ value = value[: -len(_MARKER)]
48
+ return value.strip()
49
+
50
+
51
+ def _key_bytes(key: str) -> bytes:
52
+ raw = key.encode("utf-8")
53
+ if len(raw) not in (16, 24, 32):
54
+ raise ValueError(
55
+ f"Key must be 16, 24, or 32 characters (AES-128 / AES-192 / AES-256); "
56
+ f"got {len(raw)}"
57
+ )
58
+ return raw
59
+
60
+
61
+ def _ecb(key: str) -> Cipher:
62
+ return Cipher(algorithms.AES(_key_bytes(key)), modes.ECB())
63
+
64
+
65
+ def encrypt(plaintext: str, key: str) -> str:
66
+ """Encrypt plaintext into a Nokia custom-hash value: '<base64> custom'."""
67
+ padder = PKCS7(BLOCK * 8).padder()
68
+ padded = padder.update(plaintext.encode("utf-8")) + padder.finalize()
69
+ encryptor = _ecb(key).encryptor()
70
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
71
+ return f"{base64.b64encode(ciphertext).decode('ascii')} {SUFFIX}"
72
+
73
+
74
+ def decrypt(ciphertext: str, key: str) -> str:
75
+ """Decrypt a Nokia custom-hash value, with or without the ' custom' suffix.
76
+
77
+ Raises ValueError on a malformed value, a bad key length, a wrong key, or
78
+ a value not produced by this scheme.
79
+ """
80
+ b64 = _strip_suffix(ciphertext)
81
+ try:
82
+ raw = base64.b64decode(b64, validate=True)
83
+ except binascii.Error as e:
84
+ raise ValueError(f"Invalid base64 in custom-hash value: {b64!r}") from e
85
+ if len(raw) == 0 or len(raw) % BLOCK != 0:
86
+ raise ValueError(
87
+ "Invalid ciphertext: length is not a multiple of the AES block size"
88
+ )
89
+ decryptor = _ecb(key).decryptor()
90
+ decrypted = decryptor.update(raw) + decryptor.finalize()
91
+ try:
92
+ unpadder = PKCS7(BLOCK * 8).unpadder()
93
+ plaintext = unpadder.update(decrypted) + unpadder.finalize()
94
+ return plaintext.decode("utf-8")
95
+ except (ValueError, UnicodeDecodeError) as e:
96
+ raise ValueError(
97
+ "Decryption failed: wrong key, or not a valid custom-hash value"
98
+ ) from e
99
+
100
+
101
+ def check(ciphertext: str, other: str, key: str) -> tuple[str, str, bool]:
102
+ """Decrypt `ciphertext` and compare to `other`.
103
+
104
+ `other` may be a plaintext, or another custom-hash value (auto-detected by
105
+ the ' custom' suffix and decrypted with the same key). Returns
106
+ (plain_a, plain_b, match).
107
+ """
108
+ plain_a = decrypt(ciphertext, key)
109
+ plain_b = decrypt(other, key) if _has_suffix(other) else other
110
+ return plain_a, plain_b, plain_a == plain_b
File without changes
@@ -0,0 +1,46 @@
1
+ """The catalogue of supported secret formats.
2
+
3
+ One Cipher entry per format. The CLI builds its subcommands and its --list
4
+ output entirely from this list; adding a format means adding a module and one
5
+ entry here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import juniper8, juniper9, nokia_sros_custom_hash
11
+ from .types import Cipher, KeyKind
12
+
13
+ REGISTRY: list[Cipher] = [
14
+ Cipher(
15
+ id="juniper9",
16
+ vendor="Juniper",
17
+ name="Juniper $9$",
18
+ key_kind=KeyKind.NONE,
19
+ encrypt=juniper9.encrypt,
20
+ decrypt=juniper9.decrypt,
21
+ check=juniper9.check,
22
+ ),
23
+ Cipher(
24
+ id="juniper8",
25
+ vendor="Juniper",
26
+ name="Juniper $8$",
27
+ key_kind=KeyKind.MASTER_PASSWORD,
28
+ encrypt=juniper8.encrypt,
29
+ decrypt=juniper8.decrypt,
30
+ check=juniper8.check,
31
+ ),
32
+ Cipher(
33
+ id="nokia-sros-custom-hash",
34
+ vendor="Nokia",
35
+ name="Nokia SR OS custom-hash",
36
+ key_kind=KeyKind.SHARED_KEY,
37
+ encrypt=nokia_sros_custom_hash.encrypt,
38
+ decrypt=nokia_sros_custom_hash.decrypt,
39
+ check=nokia_sros_custom_hash.check,
40
+ ),
41
+ ]
42
+
43
+
44
+ def find(cipher_id: str) -> Cipher | None:
45
+ """Return the Cipher with this id, or None if there is no such cipher."""
46
+ return next((c for c in REGISTRY if c.id == cipher_id), None)
@@ -0,0 +1,43 @@
1
+ """The Cipher contract shared by every supported secret format.
2
+
3
+ Mirrors the registry-of-ciphers structure of the network-secret-website web
4
+ app: each format is one Cipher entry carrying its metadata and its three
5
+ operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+
14
+
15
+ class KeyKind(Enum):
16
+ """What kind of key a cipher needs (drives CLI key handling)."""
17
+
18
+ NONE = "none"
19
+ MASTER_PASSWORD = "master-password"
20
+ SHARED_KEY = "shared-key"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Cipher:
25
+ """A supported secret format and its operations.
26
+
27
+ encrypt / decrypt / check are the module-level functions of the cipher.
28
+ Their signatures vary by key_kind (keyless ciphers take no key argument),
29
+ so they are typed loosely here; the CLI supplies the right arguments per
30
+ key_kind.
31
+ """
32
+
33
+ id: str
34
+ vendor: str
35
+ name: str
36
+ key_kind: KeyKind
37
+ encrypt: Callable[..., str]
38
+ decrypt: Callable[..., str]
39
+ check: Callable[..., tuple[str, str, bool]]
40
+
41
+ @property
42
+ def keyed(self) -> bool:
43
+ return self.key_kind is not KeyKind.NONE
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "network-secret"
3
+ version = "0.1.0"
4
+ description = "Encode, decode, and check network device secrets: Juniper $9$, Juniper $8$, and Nokia SR OS custom-hash"
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ authors = [
8
+ { name = "Antoine Keranflec'h", email = "antoine.keranflech@gmail.com" },
9
+ ]
10
+ requires-python = ">=3.11"
11
+ keywords = ["juniper", "junos", "nokia", "sros", "password", "crypt", "decrypt", "network", "$9$", "$8$", "custom-hash"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: System Administrators",
16
+ "Intended Audience :: Telecommunications Industry",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Security :: Cryptography",
24
+ "Topic :: System :: Networking",
25
+ ]
26
+ dependencies = [
27
+ "cryptography>=42.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/antoinekh/network-secret"
32
+ Repository = "https://github.com/antoinekh/network-secret"
33
+ Issues = "https://github.com/antoinekh/network-secret/issues"
34
+
35
+ [project.scripts]
36
+ network-secret = "network_secret.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=8.0",
45
+ ]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["network_secret"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["network_secret/", "README.md", "LICENSE", "tests/"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from network_secret.cli import main
6
+
7
+
8
+ def test_version(capsys):
9
+ with pytest.raises(SystemExit) as exc:
10
+ main(["--version"])
11
+ assert exc.value.code == 0
12
+ assert "network-secret" in capsys.readouterr().out
13
+
14
+
15
+ def test_list(capsys):
16
+ assert main(["--list"]) == 0
17
+ out = capsys.readouterr().out
18
+ assert "juniper9" in out
19
+ assert "juniper8" in out
20
+ assert "nokia-sros-custom-hash" in out
21
+
22
+
23
+ def test_juniper9_round_trip(capsys):
24
+ assert main(["juniper9", "--encrypt", "hunter2"]) == 0
25
+ ct = capsys.readouterr().out.strip()
26
+ assert main(["juniper9", "--decrypt", ct]) == 0
27
+ assert capsys.readouterr().out.strip() == "hunter2"
28
+
29
+
30
+ def test_juniper8_encrypt_decrypt(capsys):
31
+ assert main(["juniper8", "-m", "MASTER", "--encrypt", "hunter2"]) == 0
32
+ ct = capsys.readouterr().out.strip()
33
+ assert main(["juniper8", "-m", "MASTER", "--decrypt", ct]) == 0
34
+ assert capsys.readouterr().out.strip() == "hunter2"
35
+
36
+
37
+ def test_nokia_round_trip(capsys):
38
+ key = "a3f8d9e112c04b7af1c3e8b92d057a4e"
39
+ assert main(["nokia-sros-custom-hash", "-k", key, "--encrypt", "hunter2"]) == 0
40
+ ct = capsys.readouterr().out.strip()
41
+ assert main(["nokia-sros-custom-hash", "-k", key, "--decrypt", ct]) == 0
42
+ assert capsys.readouterr().out.strip() == "hunter2"
43
+
44
+
45
+ def test_check_match_returns_0(capsys):
46
+ main(["juniper9", "--encrypt", "hunter2"])
47
+ ct = capsys.readouterr().out.strip()
48
+ assert main(["juniper9", "--check", ct, "hunter2"]) == 0
49
+
50
+
51
+ def test_check_mismatch_returns_1(capsys):
52
+ main(["juniper9", "--encrypt", "hunter2"])
53
+ ct = capsys.readouterr().out.strip()
54
+ assert main(["juniper9", "--check", ct, "nope"]) == 1
55
+
56
+
57
+ def test_error_returns_2():
58
+ assert main(["juniper8", "-m", "MASTER", "--decrypt", "not-a-real-8"]) == 2
59
+
60
+
61
+ def test_nokia_key_from_env(monkeypatch, capsys):
62
+ monkeypatch.setenv("SROS_CUSTOM_HASH_KEY", "a3f8d9e112c04b7af1c3e8b92d057a4e")
63
+ assert main(["nokia-sros-custom-hash", "--encrypt", "hunter2"]) == 0
64
+ # The Nokia format appends " custom" as a marker, so a successful encrypt
65
+ # with the env-var key produces a value ending in "custom".
66
+ assert capsys.readouterr().out.strip().endswith("custom")
67
+
68
+
69
+ def test_juniper8_master_from_env(monkeypatch, capsys):
70
+ monkeypatch.setenv("JUNOS_MASTER_PASSWORD", "MASTER")
71
+ assert main(["juniper8", "--encrypt", "hunter2"]) == 0
72
+ ct = capsys.readouterr().out.strip()
73
+ assert main(["juniper8", "--decrypt", ct]) == 0
74
+ assert capsys.readouterr().out.strip() == "hunter2"
75
+
76
+
77
+ def test_check_output_format(capsys):
78
+ main(["juniper9", "--encrypt", "hunter2"])
79
+ ct = capsys.readouterr().out.strip()
80
+ main(["juniper9", "--check", ct, "hunter2"])
81
+ out = capsys.readouterr().out
82
+ assert "Value 1 : 'hunter2'" in out
83
+ assert "Value 2 : 'hunter2'" in out
84
+ assert "Match : YES" in out
@@ -0,0 +1,72 @@
1
+ """pytest suite for network_secret.juniper8."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from network_secret import juniper8
8
+
9
+ MASTER = "master-secret"
10
+
11
+ # Master password and $8$ values captured from a real JUNOS 23.2 device.
12
+ # Source: juniper8-crypt upstream test suite.
13
+ DEVICE_MASTER = "a3f8d9e112c04b7af1c3e8b92d057a4e"
14
+
15
+ KNOWN_VECTORS = [
16
+ (
17
+ "$8$aes256-gcm$hmac-sha2-256$100$p8XEvHtxRNE$d/hqRmh5etkBzo7WSdtvjg$"
18
+ "7w1eMTYXkz4RdzMF9CAkJQ$qVLunbFwBWwyxln2Vg",
19
+ "LabBgpSecret1",
20
+ ),
21
+ (
22
+ "$8$aes256-gcm$hmac-sha2-256$100$32kBriS21/k$0O08cy0znzu4nrcHxbhMmA$"
23
+ "PP0OeY9ANX2UDT1FTDVpiQ$gTrzX/ZppBbu42TpRtw",
24
+ "LabIsisSecret1",
25
+ ),
26
+ ]
27
+
28
+
29
+ @pytest.mark.parametrize("ciphertext,expected", KNOWN_VECTORS)
30
+ def test_decrypt_device_known_vectors(ciphertext: str, expected: str) -> None:
31
+ """Device-verified known-answer test: proves cross-version parity."""
32
+ assert juniper8.decrypt(ciphertext, DEVICE_MASTER) == expected
33
+
34
+
35
+ def test_round_trip():
36
+ ct = juniper8.encrypt("hunter2", MASTER)
37
+ assert juniper8.decrypt(ct, MASTER) == "hunter2"
38
+
39
+
40
+ def test_non_deterministic():
41
+ assert juniper8.encrypt("x", MASTER) != juniper8.encrypt("x", MASTER)
42
+
43
+
44
+ def test_wrong_master_fails():
45
+ ct = juniper8.encrypt("hunter2", MASTER)
46
+ with pytest.raises(ValueError):
47
+ juniper8.decrypt(ct, "wrong-master")
48
+
49
+
50
+ def test_check_match_and_mismatch():
51
+ ct = juniper8.encrypt("hunter2", MASTER)
52
+ assert juniper8.check(ct, "hunter2", MASTER)[2] is True
53
+ assert juniper8.check(ct, "nope", MASTER)[2] is False
54
+
55
+
56
+ def test_check_other_is_ciphertext():
57
+ a = juniper8.encrypt("same", MASTER)
58
+ b = juniper8.encrypt("same", MASTER)
59
+ assert juniper8.check(a, b, MASTER)[2] is True
60
+
61
+
62
+ def test_decrypt_rejects_non_magic():
63
+ with pytest.raises(ValueError):
64
+ juniper8.decrypt("$9$nope", MASTER)
65
+
66
+
67
+ def test_decrypt_rejects_non_ascii_iteration_count():
68
+ # str.isdigit() is True for non-ASCII digits (e.g. superscript "²") that
69
+ # int() cannot parse; the dedicated message must still be raised.
70
+ value = KNOWN_VECTORS[0][0].replace("$100$", "$²$", 1)
71
+ with pytest.raises(ValueError, match="Invalid iteration count"):
72
+ juniper8.decrypt(value, DEVICE_MASTER)
@@ -0,0 +1,33 @@
1
+ """pytest suite for network_secret.juniper9."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from network_secret import juniper9
8
+
9
+
10
+ def test_known_answer_decrypt():
11
+ # Real vector from juniper9-crypt test suite: "$9$FNkC3/t1IcevLuOWx" decrypts to "hello".
12
+ assert juniper9.decrypt("$9$FNkC3/t1IcevLuOWx") == "hello"
13
+
14
+
15
+ def test_round_trip():
16
+ assert juniper9.decrypt(juniper9.encrypt("S3cr3t!")) == "S3cr3t!"
17
+
18
+
19
+ def test_check_match_and_mismatch():
20
+ ct = juniper9.encrypt("hunter2")
21
+ assert juniper9.check(ct, "hunter2")[2] is True
22
+ assert juniper9.check(ct, "nope")[2] is False
23
+
24
+
25
+ def test_check_other_is_ciphertext():
26
+ a = juniper9.encrypt("same")
27
+ b = juniper9.encrypt("same")
28
+ assert juniper9.check(a, b)[2] is True
29
+
30
+
31
+ def test_decrypt_rejects_non_magic():
32
+ with pytest.raises(ValueError):
33
+ juniper9.decrypt("not-a-9-string")
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from network_secret import nokia_sros_custom_hash as nokia
6
+
7
+ # Known-answer vector shared with the network-secret-website web app.
8
+ KEY = "a3f8d9e112c04b7af1c3e8b92d057a4e" # 32 chars -> AES-256
9
+ PLAINTEXT = "L@bS3cr3t!"
10
+ ENCODED = "Xfs39BMeblOtlorgwTChxQ== custom"
11
+
12
+
13
+ def test_known_answer_decrypt():
14
+ assert nokia.decrypt(ENCODED, KEY) == PLAINTEXT
15
+
16
+
17
+ def test_known_answer_encrypt_is_deterministic():
18
+ # ECB is deterministic: encrypt reproduces the exact vector.
19
+ assert nokia.encrypt(PLAINTEXT, KEY) == ENCODED
20
+
21
+
22
+ def test_round_trip():
23
+ ct = nokia.encrypt("hunter2", KEY)
24
+ assert nokia.decrypt(ct, KEY) == "hunter2"
25
+
26
+
27
+ def test_decrypt_without_suffix():
28
+ assert nokia.decrypt(ENCODED.removesuffix(" custom"), KEY) == PLAINTEXT
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ "value",
33
+ [
34
+ "Xfs39BMeblOtlorgwTChxQ== custom", # canonical: one space marker
35
+ "Xfs39BMeblOtlorgwTChxQ== CUSTOM", # case-insensitive
36
+ " Xfs39BMeblOtlorgwTChxQ== custom ", # surrounding whitespace trimmed
37
+ ],
38
+ )
39
+ def test_decrypt_suffix_variants(value):
40
+ # SR OS writes "<base64> custom" with exactly one space; matching is
41
+ # case-insensitive and surrounding whitespace is trimmed.
42
+ assert nokia.decrypt(value, KEY) == PLAINTEXT
43
+
44
+
45
+ @pytest.mark.parametrize("klen", [16, 24, 32])
46
+ def test_accepts_128_192_256(klen):
47
+ key = "k" * klen
48
+ assert nokia.decrypt(nokia.encrypt("x", key), key) == "x"
49
+
50
+
51
+ @pytest.mark.parametrize("klen", [15, 17, 31, 33])
52
+ def test_rejects_bad_key_length(klen):
53
+ with pytest.raises(ValueError):
54
+ nokia.encrypt("x", "k" * klen)
55
+
56
+
57
+ def test_wrong_key_fails():
58
+ ct = nokia.encrypt("hunter2", KEY)
59
+ with pytest.raises(ValueError):
60
+ nokia.decrypt(ct, "b" * 32)
61
+
62
+
63
+ def test_check_match_and_mismatch():
64
+ assert nokia.check(ENCODED, PLAINTEXT, KEY)[2] is True
65
+ assert nokia.check(ENCODED, "nope", KEY)[2] is False
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import network_secret
4
+
5
+
6
+ def test_modules_exposed():
7
+ assert network_secret.juniper8.decrypt(
8
+ network_secret.juniper8.encrypt("x", "M"), "M"
9
+ ) == "x"
10
+ assert network_secret.juniper9.decrypt(
11
+ network_secret.juniper9.encrypt("x")
12
+ ) == "x"
13
+ key = "a3f8d9e112c04b7af1c3e8b92d057a4e"
14
+ assert network_secret.nokia_sros_custom_hash.decrypt(
15
+ network_secret.nokia_sros_custom_hash.encrypt("x", key), key
16
+ ) == "x"
17
+
18
+
19
+ def test_version_is_string():
20
+ assert isinstance(network_secret.__version__, str)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from network_secret.registry import REGISTRY, find
4
+ from network_secret.types import KeyKind
5
+
6
+
7
+ def test_registry_ids_and_order():
8
+ assert [c.id for c in REGISTRY] == [
9
+ "juniper9",
10
+ "juniper8",
11
+ "nokia-sros-custom-hash",
12
+ ]
13
+
14
+
15
+ def test_find_returns_cipher_or_none():
16
+ assert find("juniper8").vendor == "Juniper"
17
+ assert find("does-not-exist") is None
18
+
19
+
20
+ def test_key_kinds():
21
+ assert find("juniper9").key_kind is KeyKind.NONE
22
+ assert find("juniper8").key_kind is KeyKind.MASTER_PASSWORD
23
+ assert find("nokia-sros-custom-hash").key_kind is KeyKind.SHARED_KEY
24
+
25
+
26
+ def test_operations_wired():
27
+ j9 = find("juniper9")
28
+ assert j9.decrypt(j9.encrypt("hunter2")) == "hunter2"
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from network_secret.types import Cipher, KeyKind
4
+
5
+
6
+ def _noop_check(a: str, b: str) -> tuple[str, str, bool]:
7
+ return a, b, a == b
8
+
9
+
10
+ def test_keyed_is_false_only_for_none_key_kind():
11
+ keyless = Cipher(
12
+ id="x", vendor="V", name="X", key_kind=KeyKind.NONE,
13
+ encrypt=lambda s: s, decrypt=lambda s: s, check=_noop_check,
14
+ )
15
+ keyed = Cipher(
16
+ id="y", vendor="V", name="Y", key_kind=KeyKind.MASTER_PASSWORD,
17
+ encrypt=lambda s: s, decrypt=lambda s: s, check=_noop_check,
18
+ )
19
+ assert keyless.keyed is False
20
+ assert keyed.keyed is True