Skip to content

Commit

Permalink
parsing of ssh-keygen allowed signers file format
Browse files Browse the repository at this point in the history
  • Loading branch information
castedo committed Jan 4, 2025
1 parent 1c07def commit c2c1532
Show file tree
Hide file tree
Showing 2 changed files with 293 additions and 0 deletions.
154 changes: 154 additions & 0 deletions dulwich/allowed_signers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# 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 TextIO, TYPE_CHECKING, Union


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: Union[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
139 changes: 139 additions & 0 deletions tests/test_allowed_signers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 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))

0 comments on commit c2c1532

Please sign in to comment.