authkeyline 0.2.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.
Files changed (44) hide show
  1. authkeyline-0.2.0/.gitattributes +1 -0
  2. authkeyline-0.2.0/.gitignore +16 -0
  3. authkeyline-0.2.0/CHANGELOG.md +49 -0
  4. authkeyline-0.2.0/CONTRIBUTING.md +19 -0
  5. authkeyline-0.2.0/LICENSE +21 -0
  6. authkeyline-0.2.0/PKG-INFO +221 -0
  7. authkeyline-0.2.0/README.md +184 -0
  8. authkeyline-0.2.0/pyproject.toml +76 -0
  9. authkeyline-0.2.0/requirements-dev.txt +3 -0
  10. authkeyline-0.2.0/scripts/check.ps1 +16 -0
  11. authkeyline-0.2.0/scripts/fetch_upstream.py +39 -0
  12. authkeyline-0.2.0/scripts/release.ps1 +28 -0
  13. authkeyline-0.2.0/src/authkeyline/__init__.py +27 -0
  14. authkeyline-0.2.0/src/authkeyline/__main__.py +9 -0
  15. authkeyline-0.2.0/src/authkeyline/_blob.py +94 -0
  16. authkeyline-0.2.0/src/authkeyline/_cert.py +65 -0
  17. authkeyline-0.2.0/src/authkeyline/_cli.py +125 -0
  18. authkeyline-0.2.0/src/authkeyline/_codec.py +142 -0
  19. authkeyline-0.2.0/src/authkeyline/_curves.py +64 -0
  20. authkeyline-0.2.0/src/authkeyline/_errors.py +15 -0
  21. authkeyline-0.2.0/src/authkeyline/_keycheck.py +15 -0
  22. authkeyline-0.2.0/src/authkeyline/_keytypes.py +75 -0
  23. authkeyline-0.2.0/src/authkeyline/_model.py +69 -0
  24. authkeyline-0.2.0/src/authkeyline/_options.py +97 -0
  25. authkeyline-0.2.0/src/authkeyline/_optrules.py +101 -0
  26. authkeyline-0.2.0/src/authkeyline/_records.py +58 -0
  27. authkeyline-0.2.0/src/authkeyline/_wire.py +76 -0
  28. authkeyline-0.2.0/src/authkeyline/py.typed +0 -0
  29. authkeyline-0.2.0/tests/keyfactory.py +124 -0
  30. authkeyline-0.2.0/tests/test_authopt.py +73 -0
  31. authkeyline-0.2.0/tests/test_blob.py +152 -0
  32. authkeyline-0.2.0/tests/test_build.py +107 -0
  33. authkeyline-0.2.0/tests/test_certificates.py +100 -0
  34. authkeyline-0.2.0/tests/test_cli.py +83 -0
  35. authkeyline-0.2.0/tests/test_contract.py +38 -0
  36. authkeyline-0.2.0/tests/test_docs.py +75 -0
  37. authkeyline-0.2.0/tests/test_e2e_wheel.py +106 -0
  38. authkeyline-0.2.0/tests/test_file.py +38 -0
  39. authkeyline-0.2.0/tests/test_options.py +195 -0
  40. authkeyline-0.2.0/tests/test_oracle.py +174 -0
  41. authkeyline-0.2.0/tests/test_parse.py +127 -0
  42. authkeyline-0.2.0/tests/test_review_fixes.py +126 -0
  43. authkeyline-0.2.0/tests/test_sources.py +122 -0
  44. authkeyline-0.2.0/tests/upstream/MANIFEST.json +236 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Fixed
12
+
13
+ - Option values are dequoted as sshd does it: only `\"` is an escape, other backslashes
14
+ are kept. 0.1.0 dropped them, so a parsed and rebuilt `command=` could change.
15
+ - `build_line` could be steered into extra options, lines or keys through option names,
16
+ values ending in a backslash, line breaks in values or comments, or a key type with
17
+ spaces. It now refuses them and checks that the line reads back as the same record.
18
+ - Key types are recognised by name from OpenSSH's table instead of by prefix, so
19
+ `rsa-sha2-256` lines parse and option fields such as `ssh-x` are not taken for keys.
20
+
21
+ ### Added
22
+
23
+ - Options are checked like sshd: known names only (case-insensitive), quoted values,
24
+ single `command`, `principals` and `from`, `environment`, `permitopen`,
25
+ `permitlisten`, `tunnel` and `expiry-time` formats, and sshd's count limits.
26
+ - The key blob is decoded and checked: embedded type and curve agree with the line,
27
+ Ed25519 and ML-DSA-44 key sizes, ECDSA points on the curve, security key application,
28
+ RSA moduli of at least 1024 bits, certificates (OpenSSH format, signature not verified).
29
+ - `AuthorizedKey.public_key()`, `fingerprint()` (SHA256 and MD5 as `ssh-keygen -l` prints
30
+ them), `values()`, `has_flag()` and `environment()`; `PublicKey` and `Certificate`.
31
+ - `check_file` lists every broken line; `AuthKeyError` carries `reason` and `line`.
32
+ - `KEY_TYPES`, the key type names sshd accepts.
33
+ - Lines are read as sshd reads them: a NUL ends the line, CR and other C whitespace are
34
+ ignored inside the blob and allowed in the comment, the base64 must be canonical (no
35
+ set spare bits), numbers in `tunnel` and ports may carry a sign or leading blanks, and
36
+ an `expiry-time` at the UTC epoch is refused.
37
+ - Certificate signature types must fit the CA key; the signature itself is not verified.
38
+ - The `authkeyline` command (and `python -m authkeyline`): an `ssh-keygen -l` style
39
+ listing that keeps comment bytes as they are, `--json`, `--check`, `-E md5`; exit
40
+ status 1 for broken lines and 2 for unreadable files.
41
+
42
+
43
+ ## [0.1.0]
44
+
45
+ - Parse one `authorized_keys` line into an `AuthorizedKey` with `parse_line`.
46
+ - Build a line back from a record with `build_line`.
47
+ - Read a whole file, skipping blanks and comments, with `parse_file`.
48
+ - Typed records: `AuthorizedKey`, `KeyOption`, and `key_bytes()` to decode the blob.
49
+ - `AuthKeyError` for lines that are not valid entries.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/authkeyline/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
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,221 @@
1
+ Metadata-Version: 2.5
2
+ Name: authkeyline
3
+ Version: 0.2.0
4
+ Summary: Parse, check and safely build OpenSSH authorized_keys lines the way sshd reads them, with fingerprints, typed and zero-dependency
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/authkeyline
7
+ Project-URL: Issues, https://github.com/JaviLianes8/authkeyline/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/authkeyline/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: authorized_keys,certificate,ecdsa,ed25519,fingerprint,openssh,public-key,security-key,ssh,sshd
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Security
27
+ Classifier: Topic :: System :: Systems Administration
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.10
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == 'dev'
32
+ Requires-Dist: mypy; extra == 'dev'
33
+ Requires-Dist: pytest; extra == 'dev'
34
+ Requires-Dist: ruff; extra == 'dev'
35
+ Requires-Dist: twine; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # authkeyline
39
+
40
+ Parse, check and safely build OpenSSH `authorized_keys` lines into typed records,
41
+ the way sshd reads them. Zero dependencies.
42
+
43
+ ## Install
44
+
45
+ ```
46
+ pip install authkeyline
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ The examples use a made-up Ed25519 key (its 32 bytes are 0 to 31); every block runs as
52
+ part of the test suite and prints what its comments say.
53
+
54
+ ```python
55
+ from authkeyline import parse_line
56
+
57
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
58
+ key = parse_line(
59
+ f'restrict,command="/usr/bin/backup --all",from="10.0.0.1,10.0.0.2" ssh-ed25519 {BLOB} alice@host'
60
+ )
61
+
62
+ print(key.key_type, key.comment)
63
+ print(key.values("from"))
64
+ print(key.has_flag("RESTRICT"))
65
+ print(key.fingerprint())
66
+ print(key.fingerprint("md5"))
67
+ # ssh-ed25519 alice@host
68
+ # ('10.0.0.1,10.0.0.2',)
69
+ # True
70
+ # SHA256:ZkAslGjFiUHdGf/WUL8rQvkib4PTvQatUV0OUQSncCA
71
+ # MD5:0f:a2:0a:d7:38:3e:65:45:08:6b:63:84:1c:ff:dc:ba
72
+ ```
73
+
74
+ Option values come back exactly as sshd sees them. Only `\"` is an escape inside the
75
+ quotes; every other backslash is kept:
76
+
77
+ ```python
78
+ from authkeyline import parse_line
79
+
80
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
81
+ key = parse_line(f'command="echo \\"hi\\" c:\\\\temp",environment="LANG=C" ssh-ed25519 {BLOB}')
82
+ print(key.options[0].value)
83
+ print(key.environment())
84
+ # echo "hi" c:\\temp
85
+ # {'LANG': 'C'}
86
+ ```
87
+
88
+ `parse_line` refuses what sshd refuses, with the reason: unknown options, unquoted
89
+ values, a repeated `command`, a malformed `permitopen`, `environment` or `expiry-time`,
90
+ an unknown key type, a blob that is not base64, whose embedded type differs from the line,
91
+ whose Ed25519 key is not 32 bytes, whose ECDSA point is off the curve, or whose RSA modulus
92
+ is under 1024 bits:
93
+
94
+ ```python
95
+ from authkeyline import AuthKeyError, parse_line
96
+
97
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
98
+ for line in [f"command=ls ssh-ed25519 {BLOB}", f"ssh-rsa {BLOB}", "no-pty ssh-ed25519 AAAA"]:
99
+ try:
100
+ parse_line(line)
101
+ except AuthKeyError as error:
102
+ print(error)
103
+ # value of 'command' must be in double quotes
104
+ # line says 'ssh-rsa' but the key blob holds 'ssh-ed25519'
105
+ # key blob is truncated
106
+ ```
107
+
108
+ ### Building lines safely
109
+
110
+ `build_line` writes a record back and refuses anything that would not read back as the
111
+ same record: unknown option names, values with line breaks or a trailing backslash, a
112
+ comment that breaks the line. Values cannot smuggle in other options or keys.
113
+
114
+ ```python
115
+ from authkeyline import AuthKeyError, KeyOption, build_line, parse_line
116
+ import dataclasses
117
+
118
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
119
+ public = parse_line(f"ssh-ed25519 {BLOB} deploy")
120
+ restricted = dataclasses.replace(
121
+ public, options=(KeyOption("restrict"), KeyOption("command", 'rsync --server "a b"'))
122
+ )
123
+ print(build_line(restricted))
124
+ try:
125
+ build_line(dataclasses.replace(public, comment="x\nssh-ed25519 AAAA intruder"))
126
+ except AuthKeyError as error:
127
+ print(error)
128
+ # restrict,command="rsync --server \"a b\"" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f deploy
129
+ # comment holds a NUL or a line break
130
+ ```
131
+
132
+ ### Whole files
133
+
134
+ ```python
135
+ from authkeyline import check_file, parse_file
136
+
137
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
138
+ text = f"# team keys\nssh-ed25519 {BLOB} one\n\nbogus ssh-ed25519 {BLOB} two\n"
139
+ print(check_file(text))
140
+ print(len(parse_file(text.replace("bogus ", ""))))
141
+ # [LineProblem(line=4, message="unknown option 'bogus'")]
142
+ # 2
143
+ ```
144
+
145
+ `parse_file` raises on the first bad line with `error.line` set; `check_file` lists them
146
+ all. Blank lines and `#` comments are skipped, as sshd does. To read a real file, pass
147
+ its text: `parse_file(Path.home().joinpath(".ssh/authorized_keys").read_text())`.
148
+
149
+ ### Command line
150
+
151
+ ```
152
+ authkeyline ~/.ssh/authorized_keys # one line per key, like ssh-keygen -l
153
+ authkeyline -E md5 authorized_keys # MD5 fingerprints
154
+ authkeyline --json authorized_keys # options, sizes, certificate details
155
+ authkeyline --check authorized_keys # only report broken lines
156
+ ```
157
+
158
+ Broken lines go to standard error as `FILE:LINE: reason` and the exit status is 1; a file
159
+ that cannot be read gives status 2. The listing matches `ssh-keygen -l` byte for byte,
160
+ comments in any encoding included (the test suite compares them).
161
+
162
+ ## API
163
+
164
+ | Name | What it does |
165
+ | --- | --- |
166
+ | `parse_line(line)` | One line to an `AuthorizedKey`, checked like sshd; raises `AuthKeyError` |
167
+ | `parse_file(text)` | Every key of a file; the first bad line raises with `.line` set |
168
+ | `check_file(text)` | Every broken line as a `LineProblem(line, message)` |
169
+ | `build_line(key)` | A record back to a line that reads back the same, or `AuthKeyError` |
170
+ | `AuthorizedKey` | `options`, `key_type`, `key_base64`, `comment`; `public_key()`, `fingerprint()`, `values()`, `has_flag()`, `environment()`, `key_bytes()` |
171
+ | `KeyOption` | `name` as written and `value` (`None` for a flag) |
172
+ | `PublicKey` | `key_type`, `short_type`, `bits`, `application`, `certificate`, `fingerprint()` |
173
+ | `Certificate` | `cert_type`, `serial`, `key_id`, `principals`, validity, critical options, extensions, `ca_key`, `signature_type` |
174
+ | `LineProblem` | `line` number and `message` |
175
+ | `KEY_TYPES` | The key type names sshd accepts, certificates included |
176
+ | `AuthKeyError` | A `ValueError` with `reason` and `line` |
177
+
178
+ ## What it follows
179
+
180
+ - The options grammar and rules of OpenSSH's `sshauthopt_parse` and the option list of
181
+ sshd(8): case-insensitive names, quoted values, `no-` forms, single `command`,
182
+ `principals` and `from`, `permitopen` and `permitlisten` targets, `tunnel`,
183
+ `expiry-time`, `environment` (first value of a name wins).
184
+ - The key type table of OpenSSH (`ssh-ed25519`, `sk-ssh-ed25519@openssh.com`,
185
+ `ecdsa-sha2-nistp256/384/521`, `sk-ecdsa-sha2-nistp256@openssh.com`, `ssh-rsa`,
186
+ `ssh-mldsa44-ed25519`, their certificates, and the `rsa-sha2-256/512` names that sshd
187
+ reads as RSA keys), the blob layouts of RFC 4253, RFC 5656 and RFC 8709, and the
188
+ certificate format of draft-miller-ssh-cert.
189
+ - Fingerprints are computed over the bare key, as `ssh-keygen -l` does, so a certificate
190
+ has the fingerprint of its key.
191
+
192
+ Where it differs from sshd, on purpose:
193
+
194
+ - Certificate signatures are not verified (that needs a crypto library), so a certificate
195
+ with a forged signature parses while sshd skips it. The signature type is checked
196
+ against the CA key. Certificates are rare in `authorized_keys`; CA keys marked
197
+ `cert-authority` are plain keys and fully checked.
198
+ - `from=` patterns are kept as text, not matched against a client.
199
+ - Port names such as `host:ssh` in `permitopen` are refused, since sshd resolves them
200
+ through the local services database, which differs per system.
201
+ - `build_line` keeps comments and values to one line of text without NUL or CR. sshd
202
+ would cut a line at a NUL and ignore a CR in the comment, and `parse_line` reads such
203
+ lines the same way, but a written line never contains them.
204
+
205
+ ## Why
206
+
207
+ An `authorized_keys` line packs an options field, a key type, a base64 blob and a comment
208
+ into one string, and the options field has its own grammar. Splitting on whitespace or
209
+ commas gets it wrong, and a builder that quotes values naively lets a value add options or
210
+ whole keys. This library reads a line as sshd does (with the differences listed above),
211
+ says why sshd would skip it, and writes lines that cannot be steered.
212
+
213
+ ## License
214
+
215
+ MIT. See [LICENSE](LICENSE).
216
+
217
+ ## Links
218
+
219
+ - Website: https://lianes8studio.com
220
+ - GitHub: https://github.com/JaviLianes8
221
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,184 @@
1
+ # authkeyline
2
+
3
+ Parse, check and safely build OpenSSH `authorized_keys` lines into typed records,
4
+ the way sshd reads them. Zero dependencies.
5
+
6
+ ## Install
7
+
8
+ ```
9
+ pip install authkeyline
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ The examples use a made-up Ed25519 key (its 32 bytes are 0 to 31); every block runs as
15
+ part of the test suite and prints what its comments say.
16
+
17
+ ```python
18
+ from authkeyline import parse_line
19
+
20
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
21
+ key = parse_line(
22
+ f'restrict,command="/usr/bin/backup --all",from="10.0.0.1,10.0.0.2" ssh-ed25519 {BLOB} alice@host'
23
+ )
24
+
25
+ print(key.key_type, key.comment)
26
+ print(key.values("from"))
27
+ print(key.has_flag("RESTRICT"))
28
+ print(key.fingerprint())
29
+ print(key.fingerprint("md5"))
30
+ # ssh-ed25519 alice@host
31
+ # ('10.0.0.1,10.0.0.2',)
32
+ # True
33
+ # SHA256:ZkAslGjFiUHdGf/WUL8rQvkib4PTvQatUV0OUQSncCA
34
+ # MD5:0f:a2:0a:d7:38:3e:65:45:08:6b:63:84:1c:ff:dc:ba
35
+ ```
36
+
37
+ Option values come back exactly as sshd sees them. Only `\"` is an escape inside the
38
+ quotes; every other backslash is kept:
39
+
40
+ ```python
41
+ from authkeyline import parse_line
42
+
43
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
44
+ key = parse_line(f'command="echo \\"hi\\" c:\\\\temp",environment="LANG=C" ssh-ed25519 {BLOB}')
45
+ print(key.options[0].value)
46
+ print(key.environment())
47
+ # echo "hi" c:\\temp
48
+ # {'LANG': 'C'}
49
+ ```
50
+
51
+ `parse_line` refuses what sshd refuses, with the reason: unknown options, unquoted
52
+ values, a repeated `command`, a malformed `permitopen`, `environment` or `expiry-time`,
53
+ an unknown key type, a blob that is not base64, whose embedded type differs from the line,
54
+ whose Ed25519 key is not 32 bytes, whose ECDSA point is off the curve, or whose RSA modulus
55
+ is under 1024 bits:
56
+
57
+ ```python
58
+ from authkeyline import AuthKeyError, parse_line
59
+
60
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
61
+ for line in [f"command=ls ssh-ed25519 {BLOB}", f"ssh-rsa {BLOB}", "no-pty ssh-ed25519 AAAA"]:
62
+ try:
63
+ parse_line(line)
64
+ except AuthKeyError as error:
65
+ print(error)
66
+ # value of 'command' must be in double quotes
67
+ # line says 'ssh-rsa' but the key blob holds 'ssh-ed25519'
68
+ # key blob is truncated
69
+ ```
70
+
71
+ ### Building lines safely
72
+
73
+ `build_line` writes a record back and refuses anything that would not read back as the
74
+ same record: unknown option names, values with line breaks or a trailing backslash, a
75
+ comment that breaks the line. Values cannot smuggle in other options or keys.
76
+
77
+ ```python
78
+ from authkeyline import AuthKeyError, KeyOption, build_line, parse_line
79
+ import dataclasses
80
+
81
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
82
+ public = parse_line(f"ssh-ed25519 {BLOB} deploy")
83
+ restricted = dataclasses.replace(
84
+ public, options=(KeyOption("restrict"), KeyOption("command", 'rsync --server "a b"'))
85
+ )
86
+ print(build_line(restricted))
87
+ try:
88
+ build_line(dataclasses.replace(public, comment="x\nssh-ed25519 AAAA intruder"))
89
+ except AuthKeyError as error:
90
+ print(error)
91
+ # restrict,command="rsync --server \"a b\"" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f deploy
92
+ # comment holds a NUL or a line break
93
+ ```
94
+
95
+ ### Whole files
96
+
97
+ ```python
98
+ from authkeyline import check_file, parse_file
99
+
100
+ BLOB = "AAAAC3NzaC1lZDI1NTE5AAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
101
+ text = f"# team keys\nssh-ed25519 {BLOB} one\n\nbogus ssh-ed25519 {BLOB} two\n"
102
+ print(check_file(text))
103
+ print(len(parse_file(text.replace("bogus ", ""))))
104
+ # [LineProblem(line=4, message="unknown option 'bogus'")]
105
+ # 2
106
+ ```
107
+
108
+ `parse_file` raises on the first bad line with `error.line` set; `check_file` lists them
109
+ all. Blank lines and `#` comments are skipped, as sshd does. To read a real file, pass
110
+ its text: `parse_file(Path.home().joinpath(".ssh/authorized_keys").read_text())`.
111
+
112
+ ### Command line
113
+
114
+ ```
115
+ authkeyline ~/.ssh/authorized_keys # one line per key, like ssh-keygen -l
116
+ authkeyline -E md5 authorized_keys # MD5 fingerprints
117
+ authkeyline --json authorized_keys # options, sizes, certificate details
118
+ authkeyline --check authorized_keys # only report broken lines
119
+ ```
120
+
121
+ Broken lines go to standard error as `FILE:LINE: reason` and the exit status is 1; a file
122
+ that cannot be read gives status 2. The listing matches `ssh-keygen -l` byte for byte,
123
+ comments in any encoding included (the test suite compares them).
124
+
125
+ ## API
126
+
127
+ | Name | What it does |
128
+ | --- | --- |
129
+ | `parse_line(line)` | One line to an `AuthorizedKey`, checked like sshd; raises `AuthKeyError` |
130
+ | `parse_file(text)` | Every key of a file; the first bad line raises with `.line` set |
131
+ | `check_file(text)` | Every broken line as a `LineProblem(line, message)` |
132
+ | `build_line(key)` | A record back to a line that reads back the same, or `AuthKeyError` |
133
+ | `AuthorizedKey` | `options`, `key_type`, `key_base64`, `comment`; `public_key()`, `fingerprint()`, `values()`, `has_flag()`, `environment()`, `key_bytes()` |
134
+ | `KeyOption` | `name` as written and `value` (`None` for a flag) |
135
+ | `PublicKey` | `key_type`, `short_type`, `bits`, `application`, `certificate`, `fingerprint()` |
136
+ | `Certificate` | `cert_type`, `serial`, `key_id`, `principals`, validity, critical options, extensions, `ca_key`, `signature_type` |
137
+ | `LineProblem` | `line` number and `message` |
138
+ | `KEY_TYPES` | The key type names sshd accepts, certificates included |
139
+ | `AuthKeyError` | A `ValueError` with `reason` and `line` |
140
+
141
+ ## What it follows
142
+
143
+ - The options grammar and rules of OpenSSH's `sshauthopt_parse` and the option list of
144
+ sshd(8): case-insensitive names, quoted values, `no-` forms, single `command`,
145
+ `principals` and `from`, `permitopen` and `permitlisten` targets, `tunnel`,
146
+ `expiry-time`, `environment` (first value of a name wins).
147
+ - The key type table of OpenSSH (`ssh-ed25519`, `sk-ssh-ed25519@openssh.com`,
148
+ `ecdsa-sha2-nistp256/384/521`, `sk-ecdsa-sha2-nistp256@openssh.com`, `ssh-rsa`,
149
+ `ssh-mldsa44-ed25519`, their certificates, and the `rsa-sha2-256/512` names that sshd
150
+ reads as RSA keys), the blob layouts of RFC 4253, RFC 5656 and RFC 8709, and the
151
+ certificate format of draft-miller-ssh-cert.
152
+ - Fingerprints are computed over the bare key, as `ssh-keygen -l` does, so a certificate
153
+ has the fingerprint of its key.
154
+
155
+ Where it differs from sshd, on purpose:
156
+
157
+ - Certificate signatures are not verified (that needs a crypto library), so a certificate
158
+ with a forged signature parses while sshd skips it. The signature type is checked
159
+ against the CA key. Certificates are rare in `authorized_keys`; CA keys marked
160
+ `cert-authority` are plain keys and fully checked.
161
+ - `from=` patterns are kept as text, not matched against a client.
162
+ - Port names such as `host:ssh` in `permitopen` are refused, since sshd resolves them
163
+ through the local services database, which differs per system.
164
+ - `build_line` keeps comments and values to one line of text without NUL or CR. sshd
165
+ would cut a line at a NUL and ignore a CR in the comment, and `parse_line` reads such
166
+ lines the same way, but a written line never contains them.
167
+
168
+ ## Why
169
+
170
+ An `authorized_keys` line packs an options field, a key type, a base64 blob and a comment
171
+ into one string, and the options field has its own grammar. Splitting on whitespace or
172
+ commas gets it wrong, and a builder that quotes values naively lets a value add options or
173
+ whole keys. This library reads a line as sshd does (with the differences listed above),
174
+ says why sshd would skip it, and writes lines that cannot be steered.
175
+
176
+ ## License
177
+
178
+ MIT. See [LICENSE](LICENSE).
179
+
180
+ ## Links
181
+
182
+ - Website: https://lianes8studio.com
183
+ - GitHub: https://github.com/JaviLianes8
184
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "authkeyline"
7
+ version = "0.2.0"
8
+ description = "Parse, check and safely build OpenSSH authorized_keys lines the way sshd reads them, with fingerprints, typed and zero-dependency"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["ssh", "openssh", "authorized_keys", "sshd", "public-key", "fingerprint", "certificate", "ed25519", "ecdsa", "security-key"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Operating System :: OS Independent",
26
+ "Environment :: Console",
27
+ "Topic :: Security",
28
+ "Topic :: System :: Systems Administration",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = []
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
35
+
36
+ [project.scripts]
37
+ authkeyline = "authkeyline._cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://lianes8studio.com"
41
+ Repository = "https://github.com/JaviLianes8/authkeyline"
42
+ Issues = "https://github.com/JaviLianes8/authkeyline/issues"
43
+ Changelog = "https://github.com/JaviLianes8/authkeyline/blob/main/CHANGELOG.md"
44
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
45
+ Funding = "https://buymeacoffee.com/jlianesglrs"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/authkeyline"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
52
+ exclude = [
53
+ ".github",
54
+ "IMPROVEMENTS-round*.md",
55
+ "tests/upstream/*.c",
56
+ "tests/upstream/*.h",
57
+ "tests/upstream/*.8",
58
+ "tests/upstream/*.txt",
59
+ "tests/upstream/PROTOCOL.*",
60
+ ]
61
+
62
+ [tool.ruff]
63
+ line-length = 100
64
+ target-version = "py310"
65
+
66
+ [tool.ruff.lint]
67
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ strict = true
72
+
73
+ [tool.pytest.ini_options]
74
+ testpaths = ["tests"]
75
+ pythonpath = ["src"]
76
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,16 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ # $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
3
+ # exit code is checked explicitly: a red gate must fail, never pass silently.
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ function Invoke-Step($label, [scriptblock]$cmd) {
7
+ Write-Host "== $label =="
8
+ & $cmd
9
+ if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
10
+ }
11
+
12
+ Invoke-Step "ruff format" { ruff format --check . }
13
+ Invoke-Step "ruff lint" { ruff check . }
14
+ Invoke-Step "mypy" { mypy --strict src tests }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"
@@ -0,0 +1,39 @@
1
+ """Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
2
+
3
+ The files are not part of the repository. Their tests skip until this script has fetched
4
+ them; each download is checked against the size and sha256 in the manifest.
5
+
6
+ python scripts/fetch_upstream.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
18
+
19
+
20
+ def main() -> int:
21
+ manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
22
+ failed = 0
23
+ for name, entry in manifest.items():
24
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
25
+ body = response.read()
26
+ digest = hashlib.sha256(body).hexdigest()
27
+ if len(body) != entry["size"] or digest != entry["sha256"]:
28
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
29
+ failed += 1
30
+ continue
31
+ target = FOLDER / name
32
+ target.parent.mkdir(parents=True, exist_ok=True)
33
+ target.write_bytes(body)
34
+ print(f"{name}: ok")
35
+ return 1 if failed else 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())