-
-
Notifications
You must be signed in to change notification settings - Fork 403
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
parsing of ssh-keygen allowed signers file format
- Loading branch information
Showing
2 changed files
with
293 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |