Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

parsing of ssh-keygen allowed signers file format #1473

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions dulwich/allowed_signers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Copyright (C) 2024 E. Castedo Ellerman <[email protected]>
#
# 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
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
# and <http://www.apache.org/licenses/LICENSE-2.0> 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
138 changes: 138 additions & 0 deletions tests/test_allowed_signers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Copyright (C) 2024 E. Castedo Ellerman <[email protected]>
#
# 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
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
# and <http://www.apache.org/licenses/LICENSE-2.0> 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=",
"[email protected]",
]

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
[email protected],[email protected] {} {} {}
# A certificate authority, trusted for all principals in a domain.
*@example.com cert-authority {} {}
# A key that is accepted only for file signing.
[email protected] namespaces="file" {} {}
""".format(*rsa_key, *key0, *key1)
)
expect = [
AllowedSigner("[email protected],[email protected]", None, *rsa_key),
AllowedSigner("*@example.com", {"cert-authority": ""}, *key0),
AllowedSigner("[email protected]", {"namespaces": "file"}, *key1),
]
got = load_allowed_signers_file(text)
self.assertEqual(expect, got)

def test_no_options_and_quotes(self):
text = StringIO(
"""\
[email protected] {} {}
"[email protected]" {} {}
""".format(*key0, *key0)
)
same = AllowedSigner("[email protected]", 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(
"""\
[email protected] namespaces="git,got" {} {}
""".format(*key1)
)
expect = [
AllowedSigner(
"[email protected]",
{"namespaces": "git,got"},
*key1,
),
]
self.assertEqual(expect, load_allowed_signers_file(text))

def test_dates(self):
text = StringIO(
"""\
[email protected] valid-after="19801201",valid-before="20010201" {} {}
""".format(*key0)
)
expect = [
AllowedSigner(
"[email protected]",
{"valid-after": "19801201", "valid-before": "20010201"},
*key0,
),
]
self.assertEqual(expect, load_allowed_signers_file(text))
Loading