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.
- network_secret-0.1.0/.gitignore +9 -0
- network_secret-0.1.0/LICENSE +21 -0
- network_secret-0.1.0/PKG-INFO +171 -0
- network_secret-0.1.0/README.md +124 -0
- network_secret-0.1.0/network_secret/__init__.py +24 -0
- network_secret-0.1.0/network_secret/cli.py +126 -0
- network_secret-0.1.0/network_secret/juniper8.py +170 -0
- network_secret-0.1.0/network_secret/juniper9.py +106 -0
- network_secret-0.1.0/network_secret/nokia_sros_custom_hash.py +110 -0
- network_secret-0.1.0/network_secret/py.typed +0 -0
- network_secret-0.1.0/network_secret/registry.py +46 -0
- network_secret-0.1.0/network_secret/types.py +43 -0
- network_secret-0.1.0/pyproject.toml +54 -0
- network_secret-0.1.0/tests/__init__.py +0 -0
- network_secret-0.1.0/tests/test_cli.py +84 -0
- network_secret-0.1.0/tests/test_juniper8.py +72 -0
- network_secret-0.1.0/tests/test_juniper9.py +33 -0
- network_secret-0.1.0/tests/test_nokia_sros_custom_hash.py +65 -0
- network_secret-0.1.0/tests/test_public_api.py +20 -0
- network_secret-0.1.0/tests/test_registry.py +28 -0
- network_secret-0.1.0/tests/test_types.py +20 -0
|
@@ -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
|