authkeyline 0.2.0__py3-none-any.whl
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/__init__.py +27 -0
- authkeyline/__main__.py +9 -0
- authkeyline/_blob.py +94 -0
- authkeyline/_cert.py +65 -0
- authkeyline/_cli.py +125 -0
- authkeyline/_codec.py +142 -0
- authkeyline/_curves.py +64 -0
- authkeyline/_errors.py +15 -0
- authkeyline/_keycheck.py +15 -0
- authkeyline/_keytypes.py +75 -0
- authkeyline/_model.py +69 -0
- authkeyline/_options.py +97 -0
- authkeyline/_optrules.py +101 -0
- authkeyline/_records.py +58 -0
- authkeyline/_wire.py +76 -0
- authkeyline/py.typed +0 -0
- authkeyline-0.2.0.dist-info/METADATA +221 -0
- authkeyline-0.2.0.dist-info/RECORD +21 -0
- authkeyline-0.2.0.dist-info/WHEEL +4 -0
- authkeyline-0.2.0.dist-info/entry_points.txt +2 -0
- authkeyline-0.2.0.dist-info/licenses/LICENSE +21 -0
authkeyline/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Parse, check and safely build OpenSSH authorized_keys lines into typed records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._codec import LineProblem, build_line, check_file, parse_file, parse_line
|
|
6
|
+
from ._errors import AuthKeyError
|
|
7
|
+
from ._keytypes import KEY_TYPES as _KEY_TYPES
|
|
8
|
+
from ._model import AuthorizedKey, KeyOption
|
|
9
|
+
from ._records import Certificate, PublicKey
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
KEY_TYPES: frozenset[str] = frozenset(_KEY_TYPES)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"KEY_TYPES",
|
|
17
|
+
"AuthKeyError",
|
|
18
|
+
"AuthorizedKey",
|
|
19
|
+
"Certificate",
|
|
20
|
+
"KeyOption",
|
|
21
|
+
"LineProblem",
|
|
22
|
+
"PublicKey",
|
|
23
|
+
"build_line",
|
|
24
|
+
"check_file",
|
|
25
|
+
"parse_file",
|
|
26
|
+
"parse_line",
|
|
27
|
+
]
|
authkeyline/__main__.py
ADDED
authkeyline/_blob.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Decode and check a public key blob the way sshkey_fromb does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
|
|
8
|
+
from ._cert import read_certificate
|
|
9
|
+
from ._curves import CURVES, check_point
|
|
10
|
+
from ._errors import AuthKeyError
|
|
11
|
+
from ._keytypes import KEY_TYPES, PLAIN_NAMES, KeyType, short_name
|
|
12
|
+
from ._records import PublicKey
|
|
13
|
+
from ._wire import Reader, put_mpint, put_string
|
|
14
|
+
|
|
15
|
+
# sshkey.h SSH_RSA_MINIMUM_MODULUS_SIZE; crypto_api.h sizes of the raw public keys.
|
|
16
|
+
RSA_MIN_BITS = 1024
|
|
17
|
+
ED25519_SIZE = 32
|
|
18
|
+
MLDSA44_ED25519_SIZE = 1312 + 32
|
|
19
|
+
# sshkey_size falls back to the impl keybits for key types without a size function.
|
|
20
|
+
_FIXED_BITS = {"ed25519": 256, "ed25519-sk": 256, "mldsa44-ed25519": 512}
|
|
21
|
+
# isspace() in the C locale; b64_pton skips these anywhere in the blob.
|
|
22
|
+
_C_SPACE = frozenset(" \t\n\v\f\r")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_base64(text: str) -> bytes:
|
|
26
|
+
"""Decode as OpenSSH's b64_pton: whitespace skipped, padding and zero spare bits required."""
|
|
27
|
+
clean = "".join(char for char in text if char not in _C_SPACE)
|
|
28
|
+
try:
|
|
29
|
+
raw = base64.b64decode(clean, validate=True)
|
|
30
|
+
except (binascii.Error, ValueError) as exc:
|
|
31
|
+
raise AuthKeyError(f"key blob is not valid base64: {text[:40]!r}") from exc
|
|
32
|
+
if base64.b64encode(raw).decode("ascii") != clean:
|
|
33
|
+
raise AuthKeyError(f"key blob is not canonical base64: {text[:40]!r}")
|
|
34
|
+
return raw
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_key(blob: bytes, *, allow_cert: bool = True) -> PublicKey:
|
|
38
|
+
"""Decode a public key blob, checking every field; raise AuthKeyError if any is wrong."""
|
|
39
|
+
reader = Reader(blob)
|
|
40
|
+
name = reader.cstring()
|
|
41
|
+
kind = KEY_TYPES.get(name)
|
|
42
|
+
if kind is None:
|
|
43
|
+
raise AuthKeyError(f"key blob holds an unsupported key type {name!r}")
|
|
44
|
+
if kind.cert and not allow_cert:
|
|
45
|
+
raise AuthKeyError("a certificate cannot sign a certificate")
|
|
46
|
+
if kind.cert:
|
|
47
|
+
reader.string() # nonce
|
|
48
|
+
plain, bits, application = _read_fields(reader, kind)
|
|
49
|
+
certificate = None
|
|
50
|
+
if kind.cert:
|
|
51
|
+
certificate = read_certificate(reader, lambda ca: decode_key(ca, allow_cert=False))
|
|
52
|
+
reader.finish()
|
|
53
|
+
return PublicKey(
|
|
54
|
+
key_type=name,
|
|
55
|
+
short_type=short_name(kind),
|
|
56
|
+
bits=bits,
|
|
57
|
+
blob=blob,
|
|
58
|
+
plain_blob=plain,
|
|
59
|
+
application=application,
|
|
60
|
+
certificate=certificate,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_fields(reader: Reader, kind: KeyType) -> tuple[bytes, int, str | None]:
|
|
65
|
+
"""Read the key fields; return the plain blob ssh-keygen would write, bits, application."""
|
|
66
|
+
head = put_string(PLAIN_NAMES[(kind.family, kind.curve)].encode())
|
|
67
|
+
if kind.family == "rsa":
|
|
68
|
+
exponent, modulus = reader.mpint(), reader.mpint()
|
|
69
|
+
bits = modulus.bit_length()
|
|
70
|
+
if bits < RSA_MIN_BITS:
|
|
71
|
+
raise AuthKeyError(f"RSA modulus of {bits} bits is below the {RSA_MIN_BITS} bit floor")
|
|
72
|
+
return head + put_mpint(exponent) + put_mpint(modulus), bits, None
|
|
73
|
+
if kind.family in ("ed25519", "ed25519-sk", "mldsa44-ed25519"):
|
|
74
|
+
public = reader.string()
|
|
75
|
+
size = ED25519_SIZE if kind.family != "mldsa44-ed25519" else MLDSA44_ED25519_SIZE
|
|
76
|
+
if len(public) != size:
|
|
77
|
+
raise AuthKeyError(f"{kind.short} public key must be {size} bytes, not {len(public)}")
|
|
78
|
+
fields, bits = put_string(public), _FIXED_BITS[kind.family]
|
|
79
|
+
else:
|
|
80
|
+
assert kind.curve is not None
|
|
81
|
+
curve = reader.cstring()
|
|
82
|
+
if curve != kind.curve:
|
|
83
|
+
raise AuthKeyError(f"key blob names curve {curve!r}, its type needs {kind.curve!r}")
|
|
84
|
+
point = reader.string()
|
|
85
|
+
try:
|
|
86
|
+
check_point(kind.curve, point)
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise AuthKeyError(f"ECDSA public key {exc}") from None
|
|
89
|
+
fields, bits = put_string(curve.encode()) + put_string(point), CURVES[curve].bits
|
|
90
|
+
if not kind.family.endswith("-sk"):
|
|
91
|
+
return head + fields, bits, None
|
|
92
|
+
application = reader.cstring_bytes()
|
|
93
|
+
text = application.decode("utf-8", errors="replace")
|
|
94
|
+
return head + fields + put_string(application), bits, text
|
authkeyline/_cert.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The certificate body that follows the key fields (draft-miller-ssh-cert-06 section 2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ._errors import AuthKeyError
|
|
9
|
+
from ._keytypes import KEY_TYPES, SIGNATURE_NAMES
|
|
10
|
+
from ._records import Certificate, PublicKey
|
|
11
|
+
from ._wire import Reader
|
|
12
|
+
|
|
13
|
+
# sshkey.h SSHKEY_CERT_MAX_PRINCIPALS
|
|
14
|
+
MAX_PRINCIPALS = 256
|
|
15
|
+
_CERT_TYPES: dict[int, Literal["user", "host"]] = {1: "user", 2: "host"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_certificate(reader: Reader, decode_ca: Callable[[bytes], PublicKey]) -> Certificate:
|
|
19
|
+
"""Read serial through signature; the CA key is decoded by ``decode_ca``."""
|
|
20
|
+
serial = reader.u64()
|
|
21
|
+
type_code = reader.u32()
|
|
22
|
+
key_id = reader.cstring()
|
|
23
|
+
principals = Reader(reader.string(), "certificate principals")
|
|
24
|
+
valid_after, valid_before = reader.u64(), reader.u64()
|
|
25
|
+
critical = _pairs(reader.string(), "certificate critical options")
|
|
26
|
+
extensions = _pairs(reader.string(), "certificate extensions")
|
|
27
|
+
reader.string() # reserved
|
|
28
|
+
ca_blob = reader.string()
|
|
29
|
+
signature = Reader(reader.string(), "certificate signature")
|
|
30
|
+
signature_type = signature.cstring()
|
|
31
|
+
ca_key = decode_ca(ca_blob)
|
|
32
|
+
ca_kind = KEY_TYPES[ca_key.key_type]
|
|
33
|
+
if signature_type not in SIGNATURE_NAMES[(ca_kind.family, ca_kind.curve)]:
|
|
34
|
+
raise AuthKeyError(
|
|
35
|
+
f"certificate signature {signature_type!r} cannot come from a {ca_kind.short} CA"
|
|
36
|
+
)
|
|
37
|
+
cert_type = _CERT_TYPES.get(type_code)
|
|
38
|
+
if cert_type is None:
|
|
39
|
+
raise AuthKeyError(f"certificate type {type_code} is neither user (1) nor host (2)")
|
|
40
|
+
names: list[str] = []
|
|
41
|
+
while not principals.at_end():
|
|
42
|
+
if len(names) >= MAX_PRINCIPALS:
|
|
43
|
+
raise AuthKeyError(f"certificate lists more than {MAX_PRINCIPALS} principals")
|
|
44
|
+
names.append(principals.cstring())
|
|
45
|
+
return Certificate(
|
|
46
|
+
cert_type=cert_type,
|
|
47
|
+
serial=serial,
|
|
48
|
+
key_id=key_id,
|
|
49
|
+
principals=tuple(names),
|
|
50
|
+
valid_after=valid_after,
|
|
51
|
+
valid_before=valid_before,
|
|
52
|
+
critical_options=critical,
|
|
53
|
+
extensions=extensions,
|
|
54
|
+
ca_key=ca_key,
|
|
55
|
+
signature_type=signature_type,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pairs(data: bytes, what: str) -> tuple[tuple[str, bytes], ...]:
|
|
60
|
+
reader = Reader(data, what)
|
|
61
|
+
pairs: list[tuple[str, bytes]] = []
|
|
62
|
+
while not reader.at_end():
|
|
63
|
+
name = reader.string().decode("utf-8", errors="replace")
|
|
64
|
+
pairs.append((name, reader.string()))
|
|
65
|
+
return tuple(pairs)
|
authkeyline/_cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Command line: list the keys of authorized_keys files like ``ssh-keygen -l``, or lint them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any, Literal, TextIO
|
|
10
|
+
|
|
11
|
+
from ._codec import check_file, parse_line
|
|
12
|
+
from ._errors import AuthKeyError
|
|
13
|
+
from ._model import AuthorizedKey
|
|
14
|
+
from ._records import PublicKey
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
18
|
+
"""Run the command line; return 1 when a line is broken, 2 when a file cannot be read."""
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="authkeyline",
|
|
21
|
+
description="List or check the keys of OpenSSH authorized_keys files.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("files", nargs="*", metavar="FILE", help="files to read, - for stdin")
|
|
24
|
+
parser.add_argument("-E", dest="hash", choices=["sha256", "md5"], default="sha256")
|
|
25
|
+
parser.add_argument("--json", action="store_true", help="print the keys as JSON")
|
|
26
|
+
parser.add_argument("--check", action="store_true", help="only report broken lines")
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
names = args.files or ["-"]
|
|
29
|
+
status = 0
|
|
30
|
+
report: list[dict[str, Any]] = []
|
|
31
|
+
for name in names:
|
|
32
|
+
try:
|
|
33
|
+
text = _read(name)
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
_write(sys.stderr, f"authkeyline: {name}: {exc.strerror or exc}")
|
|
36
|
+
status = 2
|
|
37
|
+
continue
|
|
38
|
+
problems = {problem.line: problem.message for problem in check_file(text)}
|
|
39
|
+
for number, message in problems.items():
|
|
40
|
+
_write(sys.stderr, f"{name}:{number}: {message}")
|
|
41
|
+
if problems:
|
|
42
|
+
status = max(status, 1)
|
|
43
|
+
if args.check:
|
|
44
|
+
continue
|
|
45
|
+
for number, raw in enumerate(text.split("\n"), start=1):
|
|
46
|
+
if number in problems:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
key = parse_line(raw)
|
|
50
|
+
except AuthKeyError:
|
|
51
|
+
continue # blank or comment line
|
|
52
|
+
if args.json:
|
|
53
|
+
report.append(_as_json(name, number, key, args.hash))
|
|
54
|
+
else:
|
|
55
|
+
_write(sys.stdout, _listing(key, args.hash))
|
|
56
|
+
if args.json:
|
|
57
|
+
_write(sys.stdout, json.dumps(report, indent=2))
|
|
58
|
+
return status
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read(name: str) -> str:
|
|
62
|
+
"""Bytes that are not UTF-8 survive as surrogates and are written back unchanged."""
|
|
63
|
+
if name == "-":
|
|
64
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
65
|
+
return sys.stdin.read() if stream is None else _decode(stream.read())
|
|
66
|
+
with open(name, "rb") as handle:
|
|
67
|
+
return _decode(handle.read())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _decode(data: bytes) -> str:
|
|
71
|
+
return data.decode("utf-8", errors="surrogateescape")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write(stream: TextIO, text: str) -> None:
|
|
75
|
+
raw = getattr(stream, "buffer", None)
|
|
76
|
+
if raw is None:
|
|
77
|
+
stream.write(text + "\n")
|
|
78
|
+
return
|
|
79
|
+
stream.flush()
|
|
80
|
+
raw.write(text.encode("utf-8", errors="surrogateescape") + b"\n")
|
|
81
|
+
raw.flush()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _listing(key: AuthorizedKey, hash: Literal["sha256", "md5"]) -> str:
|
|
85
|
+
public = key.public_key()
|
|
86
|
+
# ssh-keygen -l reads lines up to the first CR, so the shown comment stops there too.
|
|
87
|
+
comment = key.comment.split("\r", 1)[0] or "no comment"
|
|
88
|
+
return f"{public.bits} {public.fingerprint(hash)} {comment} ({public.short_type})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _as_json(
|
|
92
|
+
name: str, number: int, key: AuthorizedKey, hash: Literal["sha256", "md5"]
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
public = key.public_key()
|
|
95
|
+
entry: dict[str, Any] = {
|
|
96
|
+
"file": name,
|
|
97
|
+
"line": number,
|
|
98
|
+
"options": [[option.name, option.value] for option in key.options],
|
|
99
|
+
"key_type": key.key_type,
|
|
100
|
+
"bits": public.bits,
|
|
101
|
+
"fingerprint": public.fingerprint(hash),
|
|
102
|
+
"comment": key.comment,
|
|
103
|
+
}
|
|
104
|
+
if public.application is not None:
|
|
105
|
+
entry["application"] = public.application
|
|
106
|
+
if public.certificate is not None:
|
|
107
|
+
entry["certificate"] = _certificate(public, hash)
|
|
108
|
+
return entry
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _certificate(public: PublicKey, hash: Literal["sha256", "md5"]) -> dict[str, Any]:
|
|
112
|
+
cert = public.certificate
|
|
113
|
+
assert cert is not None
|
|
114
|
+
return {
|
|
115
|
+
"type": cert.cert_type,
|
|
116
|
+
"serial": cert.serial,
|
|
117
|
+
"key_id": cert.key_id,
|
|
118
|
+
"principals": list(cert.principals),
|
|
119
|
+
"valid_after": cert.valid_after,
|
|
120
|
+
"valid_before": cert.valid_before,
|
|
121
|
+
"critical_options": [name for name, _ in cert.critical_options],
|
|
122
|
+
"extensions": [name for name, _ in cert.extensions],
|
|
123
|
+
"ca_fingerprint": cert.ca_key.fingerprint(hash),
|
|
124
|
+
"signature_type": cert.signature_type,
|
|
125
|
+
}
|
authkeyline/_codec.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Parse, check and rebuild OpenSSH authorized_keys lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ._errors import AuthKeyError
|
|
8
|
+
from ._keytypes import KEY_TYPES
|
|
9
|
+
from ._model import AuthorizedKey, KeyOption
|
|
10
|
+
from ._options import read_options
|
|
11
|
+
from ._optrules import VALUED, flag_name
|
|
12
|
+
|
|
13
|
+
_BLANK = " \t"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LineProblem:
|
|
18
|
+
"""A line of a file that sshd would not use, with the reason."""
|
|
19
|
+
|
|
20
|
+
line: int
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_line(line: str) -> AuthorizedKey:
|
|
25
|
+
"""Parse one authorized_keys line, checking it the way sshd does.
|
|
26
|
+
|
|
27
|
+
Certificate signatures are the one thing not verified (that needs a crypto library);
|
|
28
|
+
see :class:`Certificate`.
|
|
29
|
+
|
|
30
|
+
Raises :class:`AuthKeyError` for blank and comment lines, unknown or malformed options,
|
|
31
|
+
unknown key types, and blobs that do not decode to a key of the stated type.
|
|
32
|
+
"""
|
|
33
|
+
text = line.removesuffix("\n")
|
|
34
|
+
if "\n" in text:
|
|
35
|
+
raise AuthKeyError("text holds more than one line")
|
|
36
|
+
# sshd works on C strings: a NUL ends the line; the comment is never looked at.
|
|
37
|
+
text = text.split("\0", 1)[0].lstrip(_BLANK).rstrip(" \t\r")
|
|
38
|
+
if not text or text.startswith("#"):
|
|
39
|
+
raise AuthKeyError("not an authorized_keys entry")
|
|
40
|
+
options: tuple[KeyOption, ...] = ()
|
|
41
|
+
if _first_field(text) not in KEY_TYPES:
|
|
42
|
+
options, end = read_options(text)
|
|
43
|
+
text = text[end:].lstrip(_BLANK)
|
|
44
|
+
key_type = _first_field(text)
|
|
45
|
+
if not key_type:
|
|
46
|
+
raise AuthKeyError("missing key type")
|
|
47
|
+
if key_type not in KEY_TYPES:
|
|
48
|
+
raise AuthKeyError(f"unsupported key type {key_type!r}")
|
|
49
|
+
rest = text[len(key_type) :].lstrip(_BLANK)
|
|
50
|
+
blob = _first_field(rest)
|
|
51
|
+
if not blob:
|
|
52
|
+
raise AuthKeyError("missing key blob")
|
|
53
|
+
comment = rest[len(blob) :].lstrip(_BLANK)
|
|
54
|
+
key = AuthorizedKey(key_type=key_type, key_base64=blob, options=options, comment=comment)
|
|
55
|
+
key.public_key()
|
|
56
|
+
return key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_file(text: str) -> list[AuthorizedKey]:
|
|
60
|
+
"""Parse every key in a file, skipping blank and ``#`` comment lines.
|
|
61
|
+
|
|
62
|
+
The first bad line raises :class:`AuthKeyError` with its ``line`` number set.
|
|
63
|
+
"""
|
|
64
|
+
keys: list[AuthorizedKey] = []
|
|
65
|
+
for number, raw in _entries(text):
|
|
66
|
+
try:
|
|
67
|
+
keys.append(parse_line(raw))
|
|
68
|
+
except AuthKeyError as exc:
|
|
69
|
+
raise AuthKeyError(exc.reason, number) from None
|
|
70
|
+
return keys
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_file(text: str) -> list[LineProblem]:
|
|
74
|
+
"""Every line that sshd would skip as broken, in order; an empty list means clean.
|
|
75
|
+
|
|
76
|
+
A certificate whose signature does not verify is not caught, see :func:`parse_line`.
|
|
77
|
+
"""
|
|
78
|
+
problems: list[LineProblem] = []
|
|
79
|
+
for number, raw in _entries(text):
|
|
80
|
+
try:
|
|
81
|
+
parse_line(raw)
|
|
82
|
+
except AuthKeyError as exc:
|
|
83
|
+
problems.append(LineProblem(number, exc.reason))
|
|
84
|
+
return problems
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_line(key: AuthorizedKey) -> str:
|
|
88
|
+
"""Render an :class:`AuthorizedKey` as one line, refusing anything that would not read back.
|
|
89
|
+
|
|
90
|
+
Option names must be known flags or valued options, values cannot hold NUL, CR or LF or
|
|
91
|
+
end with a backslash, the comment cannot hold them either (sshd would cut or ignore
|
|
92
|
+
them, so a written line keeps to plain text), and the result must parse back to the
|
|
93
|
+
same record.
|
|
94
|
+
"""
|
|
95
|
+
if key.key_type not in KEY_TYPES:
|
|
96
|
+
raise AuthKeyError(f"unsupported key type {key.key_type!r}")
|
|
97
|
+
if not key.key_base64 or any(char in key.key_base64 for char in " \t\r\n\0"):
|
|
98
|
+
raise AuthKeyError("key blob must be one base64 word")
|
|
99
|
+
fields = []
|
|
100
|
+
if key.options:
|
|
101
|
+
fields.append(",".join(_build_option(option) for option in key.options))
|
|
102
|
+
fields += [key.key_type, key.key_base64]
|
|
103
|
+
if key.comment:
|
|
104
|
+
if any(char in key.comment for char in "\0\r\n"):
|
|
105
|
+
raise AuthKeyError("comment holds a NUL or a line break")
|
|
106
|
+
fields.append(key.comment)
|
|
107
|
+
line = " ".join(fields)
|
|
108
|
+
if parse_line(line) != key:
|
|
109
|
+
raise AuthKeyError("the record does not read back the same; check the fields' spacing")
|
|
110
|
+
return line
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_option(option: KeyOption) -> str:
|
|
114
|
+
name = option.name
|
|
115
|
+
if option.value is None:
|
|
116
|
+
if flag_name(name) is None:
|
|
117
|
+
raise AuthKeyError(f"{name!r} is not a flag option")
|
|
118
|
+
return name
|
|
119
|
+
if name.lower() not in VALUED:
|
|
120
|
+
raise AuthKeyError(f"{name!r} is not a valued option")
|
|
121
|
+
value = option.value
|
|
122
|
+
if any(char in value for char in "\0\r\n"):
|
|
123
|
+
raise AuthKeyError(f"value of {name!r} holds a NUL or a line break")
|
|
124
|
+
if value.endswith("\\"):
|
|
125
|
+
raise AuthKeyError(f"value of {name!r} ends with a backslash, which sshd cannot read")
|
|
126
|
+
return name + '="' + value.replace('"', '\\"') + '"'
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _first_field(text: str) -> str:
|
|
130
|
+
for index, char in enumerate(text):
|
|
131
|
+
if char in _BLANK:
|
|
132
|
+
return text[:index]
|
|
133
|
+
return text
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _entries(text: str) -> list[tuple[int, str]]:
|
|
137
|
+
entries = []
|
|
138
|
+
for number, raw in enumerate(text.split("\n"), start=1):
|
|
139
|
+
stripped = raw.lstrip(_BLANK)
|
|
140
|
+
if stripped.strip() and not stripped.startswith("#"):
|
|
141
|
+
entries.append((number, raw))
|
|
142
|
+
return entries
|
authkeyline/_curves.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Point checks for the NIST curves of ECDSA keys.
|
|
2
|
+
|
|
3
|
+
The primes and curve coefficients are those of RFC 5903 section 3,
|
|
4
|
+
https://www.rfc-editor.org/rfc/rfc5903.txt (tests/test_sources.py compares them with it).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Curve:
|
|
14
|
+
bits: int
|
|
15
|
+
p: int
|
|
16
|
+
b: int
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def size(self) -> int:
|
|
20
|
+
return (self.bits + 7) // 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
CURVES: dict[str, Curve] = {
|
|
24
|
+
"nistp256": Curve(
|
|
25
|
+
256,
|
|
26
|
+
2**256 - 2**224 + 2**192 + 2**96 - 1,
|
|
27
|
+
int("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16),
|
|
28
|
+
),
|
|
29
|
+
"nistp384": Curve(
|
|
30
|
+
384,
|
|
31
|
+
2**384 - 2**128 - 2**96 + 2**32 - 1,
|
|
32
|
+
int(
|
|
33
|
+
"B3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875A"
|
|
34
|
+
"C656398D8A2ED19D2A85C8EDD3EC2AEF",
|
|
35
|
+
16,
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
"nistp521": Curve(
|
|
39
|
+
521,
|
|
40
|
+
2**521 - 1,
|
|
41
|
+
int(
|
|
42
|
+
"0051953EB9618E1C9A1F929A21A0B68540EEA2DA725B99B315F3B8B489918EF1"
|
|
43
|
+
"09E156193951EC7E937B1652C0BD3BB1BF073573DF883D2C34F1EF451FD46B50"
|
|
44
|
+
"3F00",
|
|
45
|
+
16,
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_point(curve_name: str, point: bytes) -> None:
|
|
52
|
+
"""Raise ValueError unless ``point`` is an uncompressed point on the curve.
|
|
53
|
+
|
|
54
|
+
The curves have cofactor 1, so a point on the curve that is not at infinity is in the
|
|
55
|
+
prime order group: this is what sshkey_ec_validate_public needs as well.
|
|
56
|
+
"""
|
|
57
|
+
curve = CURVES[curve_name]
|
|
58
|
+
if len(point) != 1 + 2 * curve.size or point[0] != 0x04:
|
|
59
|
+
raise ValueError(f"is not an uncompressed {curve_name} point")
|
|
60
|
+
x = int.from_bytes(point[1 : 1 + curve.size], "big")
|
|
61
|
+
y = int.from_bytes(point[1 + curve.size :], "big")
|
|
62
|
+
p = curve.p
|
|
63
|
+
if x >= p or y >= p or (y * y - (x * x * x - 3 * x + curve.b)) % p:
|
|
64
|
+
raise ValueError(f"is not on the {curve_name} curve")
|
authkeyline/_errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The single error type of the library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthKeyError(ValueError):
|
|
7
|
+
"""Raised when text cannot be read, or a record cannot be written, as an authorized_keys entry.
|
|
8
|
+
|
|
9
|
+
``line`` holds the 1-based line number when the error comes from a whole file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, line: int | None = None) -> None:
|
|
13
|
+
super().__init__(message if line is None else f"line {line}: {message}")
|
|
14
|
+
self.reason = message
|
|
15
|
+
self.line = line
|
authkeyline/_keycheck.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The line-level key checks of sshkey_read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import AuthKeyError
|
|
6
|
+
from ._keytypes import KEY_TYPES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_type_agrees(line_type: str, blob_type: str) -> None:
|
|
10
|
+
"""sshd compares the key type, and the curve for ECDSA, not the exact name."""
|
|
11
|
+
line_kind = KEY_TYPES.get(line_type)
|
|
12
|
+
if line_kind is None:
|
|
13
|
+
raise AuthKeyError(f"unsupported key type {line_type!r}")
|
|
14
|
+
if line_kind.identity != KEY_TYPES[blob_type].identity:
|
|
15
|
+
raise AuthKeyError(f"line says {line_type!r} but the key blob holds {blob_type!r}")
|
authkeyline/_keytypes.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The key type names OpenSSH accepts in authorized_keys, from the keyimpls table of sshkey.c."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class KeyType:
|
|
10
|
+
name: str
|
|
11
|
+
family: str # rsa, ed25519, ecdsa, ecdsa-sk, ed25519-sk, mldsa44-ed25519
|
|
12
|
+
curve: str | None
|
|
13
|
+
cert: bool
|
|
14
|
+
short: str
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def identity(self) -> tuple[str, str | None, bool]:
|
|
18
|
+
"""What sshd compares between the line and the blob: key type and curve."""
|
|
19
|
+
return (self.family, self.curve, self.cert)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pair(name: str, family: str, short: str, curve: str | None = None) -> list[KeyType]:
|
|
23
|
+
cert = name.replace("@openssh.com", "") + "-cert-v01@openssh.com"
|
|
24
|
+
return [KeyType(name, family, curve, False, short), KeyType(cert, family, curve, True, short)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TYPES: list[KeyType] = [
|
|
28
|
+
*_pair("ssh-ed25519", "ed25519", "ED25519"),
|
|
29
|
+
*_pair("sk-ssh-ed25519@openssh.com", "ed25519-sk", "ED25519-SK"),
|
|
30
|
+
# ssh-mldsa-eddsa.c names the certificate without the -v01@openssh.com suffix.
|
|
31
|
+
KeyType("ssh-mldsa44-ed25519", "mldsa44-ed25519", None, False, "MLDSA44-ED25519"),
|
|
32
|
+
KeyType("ssh-mldsa44-ed25519-cert", "mldsa44-ed25519", None, True, "MLDSA44-ED25519"),
|
|
33
|
+
*_pair("ecdsa-sha2-nistp256", "ecdsa", "ECDSA", "nistp256"),
|
|
34
|
+
*_pair("ecdsa-sha2-nistp384", "ecdsa", "ECDSA", "nistp384"),
|
|
35
|
+
*_pair("ecdsa-sha2-nistp521", "ecdsa", "ECDSA", "nistp521"),
|
|
36
|
+
*_pair("sk-ecdsa-sha2-nistp256@openssh.com", "ecdsa-sk", "ECDSA-SK", "nistp256"),
|
|
37
|
+
*_pair("webauthn-sk-ecdsa-sha2-nistp256@openssh.com", "ecdsa-sk", "ECDSA-SK", "nistp256"),
|
|
38
|
+
*_pair("ssh-rsa", "rsa", "RSA"),
|
|
39
|
+
# RFC 8332 signature names map to the RSA key type in sshkey.c, so sshd reads them too.
|
|
40
|
+
*_pair("rsa-sha2-256", "rsa", "RSA"),
|
|
41
|
+
*_pair("rsa-sha2-512", "rsa", "RSA"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
KEY_TYPES: dict[str, KeyType] = {kind.name: kind for kind in _TYPES}
|
|
45
|
+
|
|
46
|
+
# The name ssh-keygen writes for each plain key when it fingerprints it.
|
|
47
|
+
PLAIN_NAMES: dict[tuple[str, str | None], str] = {
|
|
48
|
+
("ed25519", None): "ssh-ed25519",
|
|
49
|
+
("ed25519-sk", None): "sk-ssh-ed25519@openssh.com",
|
|
50
|
+
("mldsa44-ed25519", None): "ssh-mldsa44-ed25519",
|
|
51
|
+
("ecdsa", "nistp256"): "ecdsa-sha2-nistp256",
|
|
52
|
+
("ecdsa", "nistp384"): "ecdsa-sha2-nistp384",
|
|
53
|
+
("ecdsa", "nistp521"): "ecdsa-sha2-nistp521",
|
|
54
|
+
("ecdsa-sk", "nistp256"): "sk-ecdsa-sha2-nistp256@openssh.com",
|
|
55
|
+
("rsa", None): "ssh-rsa",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def short_name(kind: KeyType) -> str:
|
|
60
|
+
return kind.short + ("-CERT" if kind.cert else "")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Signature names each CA key type can produce (the sigalg entries of the sshkey.c impls).
|
|
64
|
+
SIGNATURE_NAMES: dict[tuple[str, str | None], frozenset[str]] = {
|
|
65
|
+
("ed25519", None): frozenset({"ssh-ed25519"}),
|
|
66
|
+
("ed25519-sk", None): frozenset({"sk-ssh-ed25519@openssh.com"}),
|
|
67
|
+
("mldsa44-ed25519", None): frozenset({"ssh-mldsa44-ed25519"}),
|
|
68
|
+
("ecdsa", "nistp256"): frozenset({"ecdsa-sha2-nistp256"}),
|
|
69
|
+
("ecdsa", "nistp384"): frozenset({"ecdsa-sha2-nistp384"}),
|
|
70
|
+
("ecdsa", "nistp521"): frozenset({"ecdsa-sha2-nistp521"}),
|
|
71
|
+
("ecdsa-sk", "nistp256"): frozenset(
|
|
72
|
+
{"sk-ecdsa-sha2-nistp256@openssh.com", "webauthn-sk-ecdsa-sha2-nistp256@openssh.com"}
|
|
73
|
+
),
|
|
74
|
+
("rsa", None): frozenset({"ssh-rsa", "rsa-sha2-256", "rsa-sha2-512"}),
|
|
75
|
+
}
|
authkeyline/_model.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Typed records for a single OpenSSH authorized_keys line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ._blob import decode_base64, decode_key
|
|
9
|
+
from ._keycheck import check_type_agrees
|
|
10
|
+
from ._records import PublicKey
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class KeyOption:
|
|
15
|
+
"""One entry of the leading options field.
|
|
16
|
+
|
|
17
|
+
A flag (``no-pty``) has ``value`` set to ``None``. A valued option (``command="..."``)
|
|
18
|
+
keeps the text between the quotes in ``value``, with ``\\"`` turned into ``"``.
|
|
19
|
+
``name`` is kept as written; sshd compares option names case-insensitively.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
value: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AuthorizedKey:
|
|
28
|
+
"""A parsed authorized_keys line: options, key type, base64 blob and comment."""
|
|
29
|
+
|
|
30
|
+
key_type: str
|
|
31
|
+
key_base64: str
|
|
32
|
+
options: tuple[KeyOption, ...] = ()
|
|
33
|
+
comment: str = ""
|
|
34
|
+
|
|
35
|
+
def key_bytes(self) -> bytes:
|
|
36
|
+
"""Decode the base64 blob to raw bytes."""
|
|
37
|
+
return decode_base64(self.key_base64)
|
|
38
|
+
|
|
39
|
+
def public_key(self) -> PublicKey:
|
|
40
|
+
"""Decode and check the blob; its type must agree with ``key_type``."""
|
|
41
|
+
key = decode_key(self.key_bytes())
|
|
42
|
+
check_type_agrees(self.key_type, key.key_type)
|
|
43
|
+
return key
|
|
44
|
+
|
|
45
|
+
def fingerprint(self, hash: Literal["sha256", "md5"] = "sha256") -> str:
|
|
46
|
+
"""The key fingerprint as ``ssh-keygen -l -E <hash>`` prints it."""
|
|
47
|
+
return self.public_key().fingerprint(hash)
|
|
48
|
+
|
|
49
|
+
def values(self, name: str) -> tuple[str, ...]:
|
|
50
|
+
"""The values of every option called ``name``, compared case-insensitively."""
|
|
51
|
+
wanted = name.lower()
|
|
52
|
+
return tuple(
|
|
53
|
+
option.value
|
|
54
|
+
for option in self.options
|
|
55
|
+
if option.value is not None and option.name.lower() == wanted
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def environment(self) -> dict[str, str]:
|
|
59
|
+
"""The ``environment`` options as a mapping; the first value of a name wins, as in sshd."""
|
|
60
|
+
variables: dict[str, str] = {}
|
|
61
|
+
for value in self.values("environment"):
|
|
62
|
+
name, _, text = value.partition("=")
|
|
63
|
+
variables.setdefault(name, text)
|
|
64
|
+
return variables
|
|
65
|
+
|
|
66
|
+
def has_flag(self, name: str) -> bool:
|
|
67
|
+
"""Whether the flag ``name`` (``restrict``, ``no-pty``) is set, case-insensitively."""
|
|
68
|
+
wanted = name.lower()
|
|
69
|
+
return any(o.value is None and o.name.lower() == wanted for o in self.options)
|
authkeyline/_options.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read the options field exactly as sshauthopt_parse walks it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import AuthKeyError
|
|
6
|
+
from ._model import KeyOption
|
|
7
|
+
from ._optrules import (
|
|
8
|
+
ENV_MAX,
|
|
9
|
+
PERMIT_MAX,
|
|
10
|
+
SINGLE_VALUED,
|
|
11
|
+
VALUED,
|
|
12
|
+
check_value,
|
|
13
|
+
flag_name,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_END = " \t"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_options(text: str) -> tuple[tuple[KeyOption, ...], int]:
|
|
20
|
+
"""Parse options from the start of ``text``; return them and the offset where they end.
|
|
21
|
+
|
|
22
|
+
Like sshd, an empty element between commas is skipped and a comma followed by
|
|
23
|
+
whitespace ends the field; a comma at the very end is an error.
|
|
24
|
+
"""
|
|
25
|
+
options: list[KeyOption] = []
|
|
26
|
+
pos = 0
|
|
27
|
+
while pos < len(text) and text[pos] not in _END:
|
|
28
|
+
if text[pos] != ",":
|
|
29
|
+
option, pos = _read_one(text, pos)
|
|
30
|
+
options.append(option)
|
|
31
|
+
if pos == len(text) or text[pos] in _END:
|
|
32
|
+
break
|
|
33
|
+
if text[pos] != ",":
|
|
34
|
+
raise AuthKeyError(f"unexpected {text[pos]!r} after option {option.name!r}")
|
|
35
|
+
pos += 1
|
|
36
|
+
if pos == len(text):
|
|
37
|
+
raise AuthKeyError("options end with a comma")
|
|
38
|
+
check_options(options)
|
|
39
|
+
return tuple(options), pos
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_one(text: str, pos: int) -> tuple[KeyOption, int]:
|
|
43
|
+
end = pos
|
|
44
|
+
while end < len(text) and text[end] not in ',= \t"':
|
|
45
|
+
end += 1
|
|
46
|
+
name = text[pos:end]
|
|
47
|
+
if end < len(text) and text[end] == "=":
|
|
48
|
+
if name.lower() not in VALUED:
|
|
49
|
+
raise AuthKeyError(f"unknown option {name!r}" if name else "empty option name")
|
|
50
|
+
value, end = dequote(text, end + 1, name)
|
|
51
|
+
return KeyOption(name, value), end
|
|
52
|
+
if flag_name(name) is None:
|
|
53
|
+
raise AuthKeyError(f"unknown option {name!r}" if name else f"unexpected {text[pos]!r}")
|
|
54
|
+
return KeyOption(name), end
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dequote(text: str, pos: int, name: str) -> tuple[str, int]:
|
|
58
|
+
"""opt_dequote: a backslash escapes only a double quote and is otherwise kept."""
|
|
59
|
+
if pos >= len(text) or text[pos] != '"':
|
|
60
|
+
raise AuthKeyError(f"value of {name!r} must be in double quotes")
|
|
61
|
+
out: list[str] = []
|
|
62
|
+
pos += 1
|
|
63
|
+
while pos < len(text) and text[pos] != '"':
|
|
64
|
+
if text[pos] == "\\" and text[pos + 1 : pos + 2] == '"':
|
|
65
|
+
pos += 1
|
|
66
|
+
out.append(text[pos])
|
|
67
|
+
pos += 1
|
|
68
|
+
if pos >= len(text):
|
|
69
|
+
raise AuthKeyError(f"value of {name!r} has no closing quote")
|
|
70
|
+
return "".join(out), pos + 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_options(options: list[KeyOption]) -> None:
|
|
74
|
+
"""The rules across options: single-use names, value formats, sshd's count limits."""
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
env_names: set[str] = set()
|
|
77
|
+
permits = {"permitopen": 0, "permitlisten": 0}
|
|
78
|
+
for option in options:
|
|
79
|
+
name = option.name.lower()
|
|
80
|
+
if option.value is None:
|
|
81
|
+
if flag_name(name) is None:
|
|
82
|
+
raise AuthKeyError(f"option {option.name!r} needs a value")
|
|
83
|
+
continue
|
|
84
|
+
if name not in VALUED:
|
|
85
|
+
raise AuthKeyError(f"option {option.name!r} takes no value")
|
|
86
|
+
if name in SINGLE_VALUED and name in seen:
|
|
87
|
+
raise AuthKeyError(f"option {name!r} appears more than once")
|
|
88
|
+
seen.add(name)
|
|
89
|
+
if name == "environment" and len(env_names) > ENV_MAX:
|
|
90
|
+
raise AuthKeyError("too many environment options")
|
|
91
|
+
if name in permits:
|
|
92
|
+
if permits[name] > PERMIT_MAX:
|
|
93
|
+
raise AuthKeyError(f"too many {name} options")
|
|
94
|
+
permits[name] += 1
|
|
95
|
+
check_value(name, option.value)
|
|
96
|
+
if name == "environment":
|
|
97
|
+
env_names.add(option.value.partition("=")[0])
|
authkeyline/_optrules.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Value rules of the valued options, as sshauthopt_parse and its helpers in misc.c apply them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ._errors import AuthKeyError
|
|
8
|
+
|
|
9
|
+
# The flags of sshd(8); the second group also takes a "no-" prefix (opt_flag allow_negate).
|
|
10
|
+
PLAIN_FLAGS = frozenset({"restrict", "cert-authority"})
|
|
11
|
+
NEGATABLE_FLAGS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"port-forwarding",
|
|
14
|
+
"agent-forwarding",
|
|
15
|
+
"x11-forwarding",
|
|
16
|
+
"touch-required",
|
|
17
|
+
"verify-required",
|
|
18
|
+
"pty",
|
|
19
|
+
"user-rc",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
SINGLE_VALUED = frozenset({"command", "principals", "from"})
|
|
23
|
+
VALUED = SINGLE_VALUED | {"expiry-time", "environment", "permitopen", "permitlisten", "tunnel"}
|
|
24
|
+
|
|
25
|
+
# auth-options.h SSH_AUTHOPT_ENV_MAX and SSH_AUTHOPT_PERMIT_MAX; misc.h SSH_TUNID_MAX.
|
|
26
|
+
ENV_MAX = 1024
|
|
27
|
+
PERMIT_MAX = 4096
|
|
28
|
+
TUNNEL_MAX = 0x7FFFFFFF - 2
|
|
29
|
+
NI_MAXHOST = 1025
|
|
30
|
+
|
|
31
|
+
_ENV_NAME = re.compile(r"[A-Za-z0-9_]+\Z")
|
|
32
|
+
_DIGITS = re.compile(r"[0-9]+\Z")
|
|
33
|
+
# strtonum (through strtoll) takes leading C whitespace and a sign before the digits.
|
|
34
|
+
_STRTONUM = re.compile(r"[ \t\n\v\f\r]*[+-]?[0-9]+\Z")
|
|
35
|
+
_TIME_RANGES = ((4, 6, 1, 12), (6, 8, 1, 31), (8, 10, 0, 23), (10, 12, 0, 59), (12, 14, 0, 60))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def flag_name(name: str) -> str | None:
|
|
39
|
+
"""The canonical flag a name spells, or None when it is not a flag."""
|
|
40
|
+
lower = name.lower()
|
|
41
|
+
if lower in PLAIN_FLAGS or lower in NEGATABLE_FLAGS:
|
|
42
|
+
return lower
|
|
43
|
+
if lower.startswith("no-") and lower[3:] in NEGATABLE_FLAGS:
|
|
44
|
+
return lower
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_value(name: str, value: str) -> None:
|
|
49
|
+
"""Raise AuthKeyError when sshd would refuse ``value`` for the valued option ``name``."""
|
|
50
|
+
if name == "environment":
|
|
51
|
+
env_name, sep, _ = value.partition("=")
|
|
52
|
+
if not sep or not _ENV_NAME.match(env_name):
|
|
53
|
+
raise AuthKeyError(f"environment={value!r} is not NAME=value with a word NAME")
|
|
54
|
+
elif name in ("permitopen", "permitlisten"):
|
|
55
|
+
_check_permit(name, value)
|
|
56
|
+
elif name == "tunnel":
|
|
57
|
+
if value.lower() != "any" and not _number_in(value, 0, TUNNEL_MAX):
|
|
58
|
+
raise AuthKeyError(f"tunnel={value!r} is not a device number or 'any'")
|
|
59
|
+
elif name == "expiry-time":
|
|
60
|
+
_check_time(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_permit(name: str, value: str) -> None:
|
|
64
|
+
if name == "permitlisten" and ":" not in value:
|
|
65
|
+
value = "*:" + value # a bare port listens on every address
|
|
66
|
+
if value.startswith("["):
|
|
67
|
+
close = value.find("]")
|
|
68
|
+
if close < 0:
|
|
69
|
+
raise AuthKeyError(f"{name}={value!r} has an unclosed IPv6 bracket")
|
|
70
|
+
host, rest = value[: close + 1], value[close + 1 :]
|
|
71
|
+
if rest[:1] not in (":", "/"):
|
|
72
|
+
raise AuthKeyError(f"{name}={value!r} needs host:port")
|
|
73
|
+
port = rest[1:]
|
|
74
|
+
else:
|
|
75
|
+
cut = re.search(r"[:/]", value)
|
|
76
|
+
if cut is None:
|
|
77
|
+
raise AuthKeyError(f"{name}={value!r} needs host:port")
|
|
78
|
+
host, port = value[: cut.start()], value[cut.end() :]
|
|
79
|
+
if len(host.encode()) >= NI_MAXHOST:
|
|
80
|
+
raise AuthKeyError(f"{name} host name is too long")
|
|
81
|
+
if port != "*" and not _number_in(port, 1, 65535):
|
|
82
|
+
raise AuthKeyError(f"{name}={value!r} needs a port from 1 to 65535 or *")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _number_in(text: str, low: int, high: int) -> bool:
|
|
86
|
+
return bool(_STRTONUM.match(text)) and low <= int(text) <= high
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_time(value: str) -> None:
|
|
90
|
+
text, utc = value, False
|
|
91
|
+
if len(text) > 1 and text[-1] in "zZ":
|
|
92
|
+
text, utc = text[:-1], True
|
|
93
|
+
elif len(text) > 3 and text[-3:].upper() == "UTC":
|
|
94
|
+
text, utc = text[:-3], True
|
|
95
|
+
if len(text) not in (8, 12, 14) or not _DIGITS.match(text):
|
|
96
|
+
raise AuthKeyError(f"expiry-time={value!r} is not YYYYMMDD[Z] or YYYYMMDDHHMM[SS][Z]")
|
|
97
|
+
for start, end, low, high in _TIME_RANGES:
|
|
98
|
+
if end <= len(text) and not low <= int(text[start:end]) <= high:
|
|
99
|
+
raise AuthKeyError(f"expiry-time={value!r} is out of range")
|
|
100
|
+
if utc and text.ljust(14, "0") == "19700101000000":
|
|
101
|
+
raise AuthKeyError(f"expiry-time={value!r} is the epoch, which sshd refuses")
|
authkeyline/_records.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Typed views of a decoded public key and of an OpenSSH certificate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from ._errors import AuthKeyError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Certificate:
|
|
15
|
+
"""The body of an OpenSSH certificate (draft-miller-ssh-cert).
|
|
16
|
+
|
|
17
|
+
The signature type is checked against the CA key, but the signature itself is not
|
|
18
|
+
verified: sshd verifies it and skips the line when it does not match.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
cert_type: Literal["user", "host"]
|
|
22
|
+
serial: int
|
|
23
|
+
key_id: str
|
|
24
|
+
principals: tuple[str, ...]
|
|
25
|
+
valid_after: int
|
|
26
|
+
valid_before: int
|
|
27
|
+
critical_options: tuple[tuple[str, bytes], ...]
|
|
28
|
+
extensions: tuple[tuple[str, bytes], ...]
|
|
29
|
+
ca_key: PublicKey
|
|
30
|
+
signature_type: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class PublicKey:
|
|
35
|
+
"""A decoded key blob.
|
|
36
|
+
|
|
37
|
+
``key_type`` is the name inside the blob, ``short_type`` the label ssh-keygen prints
|
|
38
|
+
(``ED25519``, ``RSA-CERT``), ``bits`` the size ssh-keygen prints. ``plain_blob`` is the
|
|
39
|
+
blob of the bare key that a fingerprint is computed over, also for a certificate.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
key_type: str
|
|
43
|
+
short_type: str
|
|
44
|
+
bits: int
|
|
45
|
+
blob: bytes
|
|
46
|
+
plain_blob: bytes
|
|
47
|
+
application: str | None = None
|
|
48
|
+
certificate: Certificate | None = None
|
|
49
|
+
|
|
50
|
+
def fingerprint(self, hash: Literal["sha256", "md5"] = "sha256") -> str:
|
|
51
|
+
"""The fingerprint as ``ssh-keygen -l -E <hash>`` prints it."""
|
|
52
|
+
if hash == "sha256":
|
|
53
|
+
digest = hashlib.sha256(self.plain_blob).digest()
|
|
54
|
+
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
|
|
55
|
+
if hash == "md5":
|
|
56
|
+
digest = hashlib.md5(self.plain_blob, usedforsecurity=False).digest()
|
|
57
|
+
return "MD5:" + ":".join(f"{byte:02x}" for byte in digest)
|
|
58
|
+
raise AuthKeyError(f"unknown fingerprint hash {hash!r}, use 'sha256' or 'md5'")
|
authkeyline/_wire.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Reader for the SSH wire types of RFC 4251 section 5, with the limits OpenSSH applies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import AuthKeyError
|
|
6
|
+
|
|
7
|
+
# sshbuf.h: SSHBUF_MAX_BIGNUM, 16384 bits; one extra zero byte may keep the top bit clear.
|
|
8
|
+
MAX_BIGNUM_BYTES = 16384 // 8
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Reader:
|
|
12
|
+
"""Consume a byte string field by field; any shortfall raises AuthKeyError."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, data: bytes, what: str = "key blob") -> None:
|
|
15
|
+
self._data = data
|
|
16
|
+
self._pos = 0
|
|
17
|
+
self._what = what
|
|
18
|
+
|
|
19
|
+
def _take(self, size: int) -> bytes:
|
|
20
|
+
end = self._pos + size
|
|
21
|
+
if end > len(self._data):
|
|
22
|
+
raise AuthKeyError(f"{self._what} is truncated")
|
|
23
|
+
chunk = self._data[self._pos : end]
|
|
24
|
+
self._pos = end
|
|
25
|
+
return chunk
|
|
26
|
+
|
|
27
|
+
def u32(self) -> int:
|
|
28
|
+
return int.from_bytes(self._take(4), "big")
|
|
29
|
+
|
|
30
|
+
def u64(self) -> int:
|
|
31
|
+
return int.from_bytes(self._take(8), "big")
|
|
32
|
+
|
|
33
|
+
def string(self) -> bytes:
|
|
34
|
+
return self._take(self.u32())
|
|
35
|
+
|
|
36
|
+
def cstring_bytes(self) -> bytes:
|
|
37
|
+
"""A string that OpenSSH reads as C text: no NUL before its last byte."""
|
|
38
|
+
raw = self.string()
|
|
39
|
+
if b"\0" in raw[:-1]:
|
|
40
|
+
raise AuthKeyError(f"{self._what} has a NUL inside a text field")
|
|
41
|
+
return raw
|
|
42
|
+
|
|
43
|
+
def cstring(self) -> str:
|
|
44
|
+
return self.cstring_bytes().decode("utf-8", errors="replace")
|
|
45
|
+
|
|
46
|
+
def mpint(self) -> int:
|
|
47
|
+
raw = self.string()
|
|
48
|
+
if raw and raw[0] & 0x80:
|
|
49
|
+
raise AuthKeyError(f"{self._what} has a negative integer")
|
|
50
|
+
if len(raw) > MAX_BIGNUM_BYTES + 1 or (len(raw) == MAX_BIGNUM_BYTES + 1 and raw[0]):
|
|
51
|
+
raise AuthKeyError(f"{self._what} has an integer larger than 16384 bits")
|
|
52
|
+
return int.from_bytes(raw, "big")
|
|
53
|
+
|
|
54
|
+
def offset(self) -> int:
|
|
55
|
+
return self._pos
|
|
56
|
+
|
|
57
|
+
def at_end(self) -> bool:
|
|
58
|
+
return self._pos == len(self._data)
|
|
59
|
+
|
|
60
|
+
def finish(self) -> None:
|
|
61
|
+
if not self.at_end():
|
|
62
|
+
raise AuthKeyError(f"{self._what} has trailing bytes")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def put_string(data: bytes) -> bytes:
|
|
66
|
+
return len(data).to_bytes(4, "big") + data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def put_mpint(value: int) -> bytes:
|
|
70
|
+
"""Minimal two's complement encoding of a non-negative integer, as sshbuf writes it."""
|
|
71
|
+
if value == 0:
|
|
72
|
+
return put_string(b"")
|
|
73
|
+
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
|
74
|
+
if raw[0] & 0x80:
|
|
75
|
+
raw = b"\0" + raw
|
|
76
|
+
return put_string(raw)
|
authkeyline/py.typed
ADDED
|
File without changes
|
|
@@ -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,21 @@
|
|
|
1
|
+
authkeyline/__init__.py,sha256=ozP-kurDXWYdy72z9P_nc3Iir0GL6uleLJ_Lv6GI2Kc,665
|
|
2
|
+
authkeyline/__main__.py,sha256=LMQBFbLv27s22HznxSiCSDcYE2h16o-9w4mufwAAeTM,129
|
|
3
|
+
authkeyline/_blob.py,sha256=cmQiJgDtoPEwPgPaUgMYcgAuZdit2VtzcNo5-QcA7wM,4008
|
|
4
|
+
authkeyline/_cert.py,sha256=R9oxN55z1fVD1eg7Up-hIeJnqF2gxlR16rkX59SEqGA,2502
|
|
5
|
+
authkeyline/_cli.py,sha256=LaSok-MyRvfT1kV0b-eJP7o1oyULRCqi7a4gEHyQRdc,4541
|
|
6
|
+
authkeyline/_codec.py,sha256=CCXc3J5typb02NuWhx3ZoV88UqEe_Kal3ehCoUmmAwc,5179
|
|
7
|
+
authkeyline/_curves.py,sha256=HPPxaG_vtV_FdAAvFkXD4AQErnSouQjmjnXeaUbfv3Q,1919
|
|
8
|
+
authkeyline/_errors.py,sha256=jfBujTyzSnTSfnkfgXnlCwRWZC2pTvwtG119Zs6_0pU,514
|
|
9
|
+
authkeyline/_keycheck.py,sha256=swcuM0ylsJ32s6SrAxFAjhdVh3_M2Z4e9I_ZJodAnJE,585
|
|
10
|
+
authkeyline/_keytypes.py,sha256=nsaLzpbsIqKPWjjpbbBJOwHxg7drZHwA-Er5Uw-CFd0,3263
|
|
11
|
+
authkeyline/_model.py,sha256=zqtJjJ9KhM1zrRoRiGC8r1_R7yhiuxH3QkJxrECC3e8,2434
|
|
12
|
+
authkeyline/_options.py,sha256=lj_Imth6Nj4fmvwj8Y3E4KIduKFEMtWECr653KF6ZzI,3579
|
|
13
|
+
authkeyline/_optrules.py,sha256=isswOXQrEeap2Jr9LHhlgI996fIK14CDK2mGSywVP9A,4028
|
|
14
|
+
authkeyline/_records.py,sha256=qYfnSu1IS_eaIds4Yw66J-pfkmtrewp8c-00e06MClE,1914
|
|
15
|
+
authkeyline/_wire.py,sha256=8J8LxqUQ7-1V5FfV0Nk6UawwYcs9mPO43DFjVnqpHjs,2436
|
|
16
|
+
authkeyline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
authkeyline-0.2.0.dist-info/METADATA,sha256=J7GNLdkO7B_PB4QsMZc9OWxM1-iL_WWfCPeH7in0LL4,9355
|
|
18
|
+
authkeyline-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
19
|
+
authkeyline-0.2.0.dist-info/entry_points.txt,sha256=pBMnMfRVEqfh-9ME47a87hJWkU_Ds9BAtLCq3Nf69cg,54
|
|
20
|
+
authkeyline-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
21
|
+
authkeyline-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|