From d9d1bc28e46b564753470f461f3820ca1cecd095 Mon Sep 17 00:00:00 2001 From: Castedo Ellerman Date: Sat, 4 Jan 2025 21:30:22 +0000 Subject: [PATCH] parsing of ssh-keygen allowed signers file format --- dulwich/allowed_signers.py | 152 ++++++++++++++++++++++++++++++++++ tests/test_allowed_signers.py | 138 ++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 dulwich/allowed_signers.py create mode 100644 tests/test_allowed_signers.py diff --git a/dulwich/allowed_signers.py b/dulwich/allowed_signers.py new file mode 100644 index 000000000..9ef561d67 --- /dev/null +++ b/dulwich/allowed_signers.py @@ -0,0 +1,152 @@ +# Copyright (C) 2024 E. Castedo Ellerman +# +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later +# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU +# General Public License as public by the Free Software Foundation; version 2.0 +# or (at your option) any later version. You can redistribute it and/or +# modify it under the terms of either of these two licenses. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# You should have received a copy of the licenses; if not, see +# for a copy of the GNU General Public License +# and for a copy of the Apache +# License, Version 2.0. +# + +"""Parsing of the ssh-keygen allowed signers format.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, TextIO + +if TYPE_CHECKING: + AllowedSignerOptions = dict[str, str] + + +@dataclass +class AllowedSigner: + principals: str + options: AllowedSignerOptions | None + key_type: str + base64_key: str + comment: str | None = None # "patterned after" sshd authorized keys file format + + @staticmethod + def parse(line: str) -> AllowedSigner: + """Parse a line of an ssh-keygen "allowed signers" file. + + Raises: + ValueError: If the line is not properly formatted. + NotImplementedError: If the public key algorithm is not supported. + """ + (principals, line) = lop_principals(line) + options = None + if detect_options(line): + (options, line) = lop_options(line) + parts = line.split(maxsplit=2) + if len(parts) < 2: + msg = "Not space-separated OpenSSH format public key ('{}')." + raise ValueError(msg.format(line)) + return AllowedSigner(principals, options, *parts) + + +def lop_principals(line: str) -> tuple[str, str]: + """Return (principals, rest_of_line).""" + if line[0] == '"': + (principals, _, line) = line[1:].partition('"') + if not line: + msg = "No matching double quote character for line ('{}')." + raise ValueError(msg.format(line)) + return (principals, line.lstrip()) + parts = line.split(maxsplit=1) + if len(parts) < 2: + raise ValueError(f"Invalid line ('{line}').") + return (parts[0], parts[1]) + + +def detect_options(line: str) -> bool: + start = line.split(maxsplit=1)[0] + return "=" in start or "," in start or start.lower() == "cert-authority" + + +def lop_options(line: str) -> tuple[AllowedSignerOptions, str]: + """Return (options, rest_of_line). + + Raises: + ValueError + """ + options: AllowedSignerOptions = dict() + while line and not line[0].isspace(): + line = lop_one_option(options, line) + return (options, line) + + +def lop_one_option(options: AllowedSignerOptions, line: str) -> str: + if lopped := lop_flag(options, line, "cert-authority"): + return lopped + if lopped := lop_option(options, line, "namespaces"): + return lopped + if lopped := lop_option(options, line, "valid-after"): + return lopped + if lopped := lop_option(options, line, "valid-before"): + return lopped + raise ValueError(f"Invalid option ('{line}').") + + +def lop_flag(options: AllowedSignerOptions, line: str, opt_name: str) -> str | None: + i = len(opt_name) + if line[:i].lower() != opt_name: + return None + options[opt_name] = "" + if line[i : i + 1] == ",": + i += 1 + return line[i:] + + +def lop_option(options: AllowedSignerOptions, line: str, opt_name: str) -> str | None: + i = len(opt_name) + if line[:i].lower() != opt_name: + return None + if opt_name in options: + raise ValueError(f"Multiple '{opt_name}' clauses ('{line}')") + if line[i : i + 2] != '="': + raise ValueError(f"Option '{opt_name}' missing '=\"' ('{line}')") + (value, _, line) = line[i + 2 :].partition('"') + if not line: + raise ValueError(f"No matching quote for option '{opt_name}' ('{line}')") + options[opt_name] = value + return line[1:] if line[0] == "," else line + + +def load_allowed_signers_file(file: TextIO | Path) -> Iterable[AllowedSigner]: + """Read public keys in "allowed signers" format per ssh-keygen. + + Raises: + ValueError: If the file is not properly formatted. + """ + # The intention of this implementation is to reproduce the behaviour of the + # parse_principals_key_and_options function of the following sshsig.c file: + # https://archive.softwareheritage.org/ + # swh:1:cnt:470b286a3a982875a48a5262b7057c4710b17fed + + if isinstance(file, Path): + with open(file, encoding="ascii") as f: + return load_allowed_signers_file(f) + ret = list() + for line in file.readlines(): + if "\f" in line: + raise ValueError(f"Form feed character not supported: ('{line}').") + if "\v" in line: + raise ValueError(f"Vertical tab character not supported: ('{line}').") + line = line.strip("\n\r") + if line and line[0] not in ["#", "\0"]: + ret.append(AllowedSigner.parse(line)) + return ret diff --git a/tests/test_allowed_signers.py b/tests/test_allowed_signers.py new file mode 100644 index 000000000..f47467373 --- /dev/null +++ b/tests/test_allowed_signers.py @@ -0,0 +1,138 @@ +# Copyright (C) 2024 E. Castedo Ellerman +# +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later +# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU +# General Public License as public by the Free Software Foundation; version 2.0 +# or (at your option) any later version. You can redistribute it and/or +# modify it under the terms of either of these two licenses. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# You should have received a copy of the licenses; if not, see +# for a copy of the GNU General Public License +# and for a copy of the Apache +# License, Version 2.0. +# + +from io import StringIO +from unittest import TestCase + +from dulwich.allowed_signers import AllowedSigner, load_allowed_signers_file + +key0 = [ + "ssh-ed25519", + "AAAAC3NzaC1lZDI1NTE5AAAAIJY08ynqE/VoH690nSN+MUxMzAbfNcMdUQr+5ltIskMt", +] +key1 = [ + "ssh-ed25519", + "AAAAC3NzaC1lZDI1NTE5AAAAIIQdQut465od3lkVyVW6038PcD/wSGX/2ij3RcQZTAqt", +] +rsa_key = [ + "ssh-rsa", + "AAAAB3NzaC1yc2EAAAADAQABAAABgQCVw5Oex+EwQLGSJGaSO1kpMgaIW44AZxzRszgP6WwsF3GFSUJqoKwUnS7/clg9SXi+dXO2UwLs2eSBVXtN6YPzGhinV+bg+6k34NuvJQ1a3pDFEE7xJw3y0aY9J1k+kDELtlMevRMl7TKOnRLqRXuoCCYJof38ycQ4PLa/mHmJOu4MYCOs0zaktu1CRrzki/mh3hnzOP175h58Rg9Gj/PWm9QIoumktXvkXitV3aEH7smhMvQ90/NIIC2MM46SxErWifR2A7A7Tz7oG3mST1q3TL7fTQ7sPrkQp64G+P/46J8FcSNXxuaYI8u7w+WQ/UkVO7XqXmyNLZ72orQ2U+OuXvQXHOUeUXklNChgoAh+jU8Pp7vFTneCDP53AcpuZZRdsqk9k6tuoKSAz6mwE6aB657GArck4lioIFpP9hLPomyY6FCjXnb9WwT2qK33zOp6lgAt3hs1w4LyMinoi0szRtt+HfppM6iweIa7nKPC9RXGFuzlt7KlnyOmqKJoqeU=", + "foo@b.ar", +] + +openssh_keys = [key0, key1, rsa_key] + + +# Many test cases are from the ssh-keygen test code: +# https://archive.softwareheritage.org/ +# swh:1:cnt:dae03706d8f0cb09fa8f8cd28f86d06c4693f0c9 + + +class ParseTests(TestCase): + def test_man_page_example(self): + # Example "ALLOWED SIGNERS" file from ssh-keygen man page. Man page source: + # https://archive.softwareheritage.org/ + # swh:1:cnt:06f0555a4ec01caf8daed84b8409dd8cb3278740 + + text = StringIO( + """\ +# Comments allowed at start of line +user1@example.com,user2@example.com {} {} {} +# A certificate authority, trusted for all principals in a domain. +*@example.com cert-authority {} {} +# A key that is accepted only for file signing. +user2@example.com namespaces="file" {} {} +""".format(*rsa_key, *key0, *key1) + ) + expect = [ + AllowedSigner("user1@example.com,user2@example.com", None, *rsa_key), + AllowedSigner("*@example.com", {"cert-authority": ""}, *key0), + AllowedSigner("user2@example.com", {"namespaces": "file"}, *key1), + ] + got = load_allowed_signers_file(text) + self.assertEqual(expect, got) + + def test_no_options_and_quotes(self): + text = StringIO( + """\ +foo@example.com {} {} +"foo@example.com" {} {} +""".format(*key0, *key0) + ) + same = AllowedSigner("foo@example.com", None, *key0) + expect = [same, same] + self.assertEqual(expect, load_allowed_signers_file(text)) + + def test_space_in_quotes(self): + text = StringIO( + """\ +"ssh-keygen parses this" {} {} +""".format(*key0) + ) + expect = [ + AllowedSigner("ssh-keygen parses this", None, *key0), + ] + self.assertEqual(expect, load_allowed_signers_file(text)) + + def test_with_comments(self): + text = StringIO( + """\ +foo@bar {} {} even without options ssh-keygen will ignore the end +""".format(*key1) + ) + expect = [ + AllowedSigner( + "foo@bar", + None, + *key1, + "even without options ssh-keygen will ignore the end", + ) + ] + self.assertEqual(expect, load_allowed_signers_file(text)) + + def test_two_namespaces(self): + text = StringIO( + """\ +foo@b.ar namespaces="git,got" {} {} +""".format(*key1) + ) + expect = [ + AllowedSigner( + "foo@b.ar", + {"namespaces": "git,got"}, + *key1, + ), + ] + self.assertEqual(expect, load_allowed_signers_file(text)) + + def test_dates(self): + text = StringIO( + """\ +foo@b.ar valid-after="19801201",valid-before="20010201" {} {} +""".format(*key0) + ) + expect = [ + AllowedSigner( + "foo@b.ar", + {"valid-after": "19801201", "valid-before": "20010201"}, + *key0, + ), + ] + self.assertEqual(expect, load_allowed_signers_file(text))