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.
@@ -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
+ ]
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m authkeyline``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
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
@@ -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}")
@@ -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)
@@ -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])
@@ -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")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ authkeyline = authkeyline._cli:main
@@ -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.