Skip to content

Commit 87c58b2

Browse files
committed
Add support for creating and validating SSHSIG format signatures
This commit adds support for new top-level asyncssh.create_sshsig() and asyncssh.validate_sshsig() functions and for an OpenSSH-compatible "allowed signers" file.
1 parent 1b5839f commit 87c58b2

10 files changed

Lines changed: 954 additions & 174 deletions

File tree

README.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,13 @@ Features
7878

7979
* Support for accessing keys managed by PuTTY's Pageant agent on Windows
8080
* Support for accessing host keys via OpenSSH's ssh-keysign
81+
* Partial support for `OpenSSH-style configuration files`__
8182
* OpenSSH-style `known_hosts file`__ support
8283
* OpenSSH-style `authorized_keys file`__ support
83-
* Partial support for `OpenSSH-style configuration files`__
84+
* Support for `creating and validating SSHSIG signatures`__
85+
86+
* Including support for OpenSSH-style allowed signers files
87+
8488
* Compatibility with OpenSSH "Encrypt then MAC" option for better security
8589
* Time and byte-count based session key renegotiation
8690
* Designed to be easy to extend to support new forms of key exchange,
@@ -92,16 +96,17 @@ __ http://asyncssh.readthedocs.io/en/stable/api.html#mac-algorithms
9296
__ http://asyncssh.readthedocs.io/en/stable/api.html#compression-algorithms
9397
__ http://asyncssh.readthedocs.io/en/stable/api.html#public-key-support
9498
__ http://asyncssh.readthedocs.io/en/stable/api.html#ssh-agent-support
99+
__ http://asyncssh.readthedocs.io/en/stable/api.html#config-file-support
95100
__ http://asyncssh.readthedocs.io/en/stable/api.html#known-hosts
96101
__ http://asyncssh.readthedocs.io/en/stable/api.html#authorized-keys
97-
__ http://asyncssh.readthedocs.io/en/stable/api.html#config-file-support
102+
__ http://asyncssh.readthedocs.io/en/stable/api.html#sshsig-support
98103

99104
License
100105
-------
101106

102107
This package is released under the following terms:
103108

104-
Copyright (c) 2013-2025 by Ron Frederick <ronf@timeheart.net> and others.
109+
Copyright (c) 2013-2026 by Ron Frederick <ronf@timeheart.net> and others.
105110

106111
This program and the accompanying materials are made available under
107112
the terms of the Eclipse Public License v2.0 which accompanies this

asyncssh/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2013-2024 by Ron Frederick <ronf@timeheart.net> and others.
1+
# Copyright (c) 2013-2026 by Ron Frederick <ronf@timeheart.net> and others.
22
#
33
# This program and the accompanying materials are made available under
44
# the terms of the Eclipse Public License v2.0 which accompanies this
@@ -112,6 +112,10 @@
112112
from .sftp import SFTPAttrs, SFTPVFSAttrs, SFTPName, SFTPLimits
113113
from .sftp import SEEK_SET, SEEK_CUR, SEEK_END
114114

115+
from .sshsig import SSHAllowedSigners
116+
from .sshsig import import_allowed_signers, read_allowed_signers
117+
from .sshsig import create_sshsig, validate_sshsig
118+
115119
from .stream import SSHSocketSessionFactory, SSHServerSessionFactory
116120
from .stream import SFTPServerFactory, SSHReader, SSHWriter
117121

@@ -159,8 +163,8 @@
159163
'SSHUNIXChannel', 'SSHUNIXSession', 'SSHWriter',
160164
'STDOUT', 'ServiceNotAvailable', 'SignalReceived', 'TerminalSizeChanged',
161165
'TimeoutError', 'connect', 'connect_agent', 'connect_reverse',
162-
'create_connection', 'create_server', 'generate_private_key',
163-
'get_server_auth_methods', 'get_server_host_key',
166+
'create_connection', 'create_server', 'create_sshsig',
167+
'generate_private_key', 'get_server_auth_methods', 'get_server_host_key',
164168
'import_authorized_keys', 'import_certificate', 'import_known_hosts',
165169
'import_private_key', 'import_public_key', 'listen', 'listen_reverse',
166170
'load_certificates', 'load_keypairs', 'load_pkcs11_keys',
@@ -169,5 +173,5 @@
169173
'read_known_hosts', 'read_private_key', 'read_private_key_list',
170174
'read_public_key', 'read_public_key_list', 'run_client', 'run_server',
171175
'scp', 'set_debug_level', 'set_default_skip_rsa_key_validation',
172-
'set_log_level', 'set_sftp_log_level'
176+
'set_log_level', 'set_sftp_log_level', 'validate_sshsig'
173177
]

asyncssh/auth_keys.py

Lines changed: 12 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2015-2024 by Ron Frederick <ronf@timeheart.net> and others.
1+
# Copyright (c) 2015-2026 by Ron Frederick <ronf@timeheart.net> and others.
22
#
33
# This program and the accompanying materials are made available under
44
# the terms of the Eclipse Public License v2.0 which accompanies this
@@ -20,6 +20,7 @@
2020

2121
"""Parser for SSH authorized_keys files"""
2222

23+
from pathlib import PurePath
2324
from typing import Dict, List, Mapping, Optional, Sequence
2425
from typing import Set, Tuple, Union, cast
2526

@@ -30,7 +31,7 @@
3031
except ImportError: # pragma: no cover
3132
_x509_available = False
3233

33-
from .misc import ip_address, read_file
34+
from .misc import FilePath, OptionsParser, ip_address, read_file
3435
from .pattern import HostPatternList, WildcardPatternList
3536
from .public_key import KeyImportError, SSHKey
3637
from .public_key import SSHX509Certificate, SSHX509CertificateChain
@@ -40,13 +41,15 @@
4041

4142
_EntryOptions = Mapping[str, object]
4243

43-
class _SSHAuthorizedKeyEntry:
44+
45+
class _SSHAuthorizedKeyEntry(OptionsParser):
4446
"""An entry in an SSH authorized_keys list"""
4547

4648
def __init__(self, line: str):
49+
super().__init__()
50+
4751
self.key: Optional[SSHKey] = None
4852
self.cert: Optional[SSHX509Certificate] = None
49-
self.options: Dict[str, object] = {}
5053

5154
try:
5255
self._import_key_or_cert(line)
@@ -151,60 +154,6 @@ def _add_subject(self, option: str, value: str) -> None:
151154
'subject': _add_subject
152155
}
153156

154-
def _add_option(self) -> None:
155-
"""Add an option value"""
156-
157-
if self._option.startswith('='):
158-
raise ValueError('Missing option name in authorized_keys')
159-
160-
if '=' in self._option:
161-
option, value = self._option.split('=', 1)
162-
163-
handler = self._handlers.get(option)
164-
if handler:
165-
handler(self, option, value)
166-
else:
167-
values = cast(List[str], self.options.setdefault(option, []))
168-
values.append(value)
169-
else:
170-
self.options[self._option] = True
171-
172-
def _parse_options(self, line: str) -> str:
173-
"""Parse options in this entry"""
174-
175-
self._option = ''
176-
177-
idx = 0
178-
quoted = False
179-
escaped = False
180-
181-
for idx, ch in enumerate(line):
182-
if escaped:
183-
self._option += ch
184-
escaped = False
185-
elif ch == '\\':
186-
escaped = True
187-
elif ch == '"':
188-
quoted = not quoted
189-
elif quoted:
190-
self._option += ch
191-
elif ch in ' \t':
192-
break
193-
elif ch == ',':
194-
self._add_option()
195-
self._option = ''
196-
else:
197-
self._option += ch
198-
199-
self._add_option()
200-
201-
if quoted:
202-
raise ValueError('Unbalanced quote in authorized_keys')
203-
elif escaped:
204-
raise ValueError('Unbalanced backslash in authorized_keys')
205-
206-
return line[idx:].strip()
207-
208157
def match_options(self, client_host: str, client_addr: str,
209158
cert_principals: Optional[Sequence[str]],
210159
cert_subject: Optional['X509Name'] = None) -> bool:
@@ -277,7 +226,7 @@ def load(self, authorized_keys: str) -> None:
277226

278227
def validate(self, key: SSHKey, client_host: str, client_addr: str,
279228
cert_principals: Optional[Sequence[str]] = None,
280-
ca: bool = False) -> Optional[Mapping[str, object]]:
229+
ca: bool = False) -> Optional[_EntryOptions]:
281230
"""Return whether a public key or CA is valid for authentication"""
282231

283232
for entry in self._ca_entries if ca else self._user_entries:
@@ -322,7 +271,7 @@ def import_authorized_keys(data: str) -> SSHAuthorizedKeys:
322271
return SSHAuthorizedKeys(data)
323272

324273

325-
def read_authorized_keys(filelist: Union[str, Sequence[str]]) -> \
274+
def read_authorized_keys(filelist: Union[FilePath, Sequence[FilePath]]) -> \
326275
SSHAuthorizedKeys:
327276
"""Read SSH authorized keys from a file or list of files
328277
@@ -331,16 +280,16 @@ def read_authorized_keys(filelist: Union[str, Sequence[str]]) -> \
331280
332281
:param filelist:
333282
The file or list of files to read the keys from.
334-
:type filenlist: `str` or `list` of `str`
283+
:type filelist: `PurePath`, `str`, or a list of these
335284
336285
:returns: An :class:`SSHAuthorizedKeys` object
337286
338287
"""
339288

340289
authorized_keys = SSHAuthorizedKeys()
341290

342-
if isinstance(filelist, str):
343-
files: Sequence[str] = [filelist]
291+
if isinstance(filelist, (PurePath, str)):
292+
files: Sequence[FilePath] = [filelist]
344293
else:
345294
files = filelist
346295

0 commit comments

Comments
 (0)