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.
- authkeyline-0.2.0/.gitattributes +1 -0
- authkeyline-0.2.0/.gitignore +16 -0
- authkeyline-0.2.0/CHANGELOG.md +49 -0
- authkeyline-0.2.0/CONTRIBUTING.md +19 -0
- authkeyline-0.2.0/LICENSE +21 -0
- authkeyline-0.2.0/PKG-INFO +221 -0
- authkeyline-0.2.0/README.md +184 -0
- authkeyline-0.2.0/pyproject.toml +76 -0
- authkeyline-0.2.0/requirements-dev.txt +3 -0
- authkeyline-0.2.0/scripts/check.ps1 +16 -0
- authkeyline-0.2.0/scripts/fetch_upstream.py +39 -0
- authkeyline-0.2.0/scripts/release.ps1 +28 -0
- authkeyline-0.2.0/src/authkeyline/__init__.py +27 -0
- authkeyline-0.2.0/src/authkeyline/__main__.py +9 -0
- authkeyline-0.2.0/src/authkeyline/_blob.py +94 -0
- authkeyline-0.2.0/src/authkeyline/_cert.py +65 -0
- authkeyline-0.2.0/src/authkeyline/_cli.py +125 -0
- authkeyline-0.2.0/src/authkeyline/_codec.py +142 -0
- authkeyline-0.2.0/src/authkeyline/_curves.py +64 -0
- authkeyline-0.2.0/src/authkeyline/_errors.py +15 -0
- authkeyline-0.2.0/src/authkeyline/_keycheck.py +15 -0
- authkeyline-0.2.0/src/authkeyline/_keytypes.py +75 -0
- authkeyline-0.2.0/src/authkeyline/_model.py +69 -0
- authkeyline-0.2.0/src/authkeyline/_options.py +97 -0
- authkeyline-0.2.0/src/authkeyline/_optrules.py +101 -0
- authkeyline-0.2.0/src/authkeyline/_records.py +58 -0
- authkeyline-0.2.0/src/authkeyline/_wire.py +76 -0
- authkeyline-0.2.0/src/authkeyline/py.typed +0 -0
- authkeyline-0.2.0/tests/keyfactory.py +124 -0
- authkeyline-0.2.0/tests/test_authopt.py +73 -0
- authkeyline-0.2.0/tests/test_blob.py +152 -0
- authkeyline-0.2.0/tests/test_build.py +107 -0
- authkeyline-0.2.0/tests/test_certificates.py +100 -0
- authkeyline-0.2.0/tests/test_cli.py +83 -0
- authkeyline-0.2.0/tests/test_contract.py +38 -0
- authkeyline-0.2.0/tests/test_docs.py +75 -0
- authkeyline-0.2.0/tests/test_e2e_wheel.py +106 -0
- authkeyline-0.2.0/tests/test_file.py +38 -0
- authkeyline-0.2.0/tests/test_options.py +195 -0
- authkeyline-0.2.0/tests/test_oracle.py +174 -0
- authkeyline-0.2.0/tests/test_parse.py +127 -0
- authkeyline-0.2.0/tests/test_review_fixes.py +126 -0
- authkeyline-0.2.0/tests/test_sources.py +122 -0
- 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,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())
|