Initial import of code from derivepassphrase
Marco Ricci

Marco Ricci commited on 2026-08-31 14:53:33
Zeige 39 geänderte Dateien mit 3331 Einfügungen und 0 Löschungen.


Based on `derivepassphrase` commit
187a871b384a0b282f5bcfb0875d7d71b0f09afc, shortly after the 0.6.1
release and the 1.0 release of
`derivepassphrase-sshagentsocketprovider`.  Minor changes were made to
keep the code runnable, e.g. restructuring the module contents, and
removing references to other `derivepassphrase` code that is no longer
included in this project.
... ...
@@ -0,0 +1,7 @@
1
+html/
2
+.mypy_cache/
3
+.pytest_cache/
4
+__pycache__/
5
+*.swp
6
+.coverage*
7
+.hypothesis/
... ...
@@ -0,0 +1,4 @@
1
+[submodule "docs/wishlist"]
2
+	path = docs/wishlist
3
+	url = ./
4
+	branch = wishlist
... ...
@@ -0,0 +1 @@
1
+(See docs/ directory.)
... ...
@@ -0,0 +1,13 @@
1
+zlib License
2
+
3
+Copyright 2026 Marco Ricci <software@the13thletter.info>
4
+
5
+This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
6
+
7
+Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
8
+
9
+ 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
10
+
11
+ 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
12
+
13
+ 3. This notice may not be removed or altered from any source distribution.
... ...
@@ -0,0 +1,35 @@
1
+# fakesshagent
2
+
3
+[![PyPI - Version](https://img.shields.io/pypi/v/fakesshagent.svg)](https://pypi.org/project/fakesshagent)
4
+[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fakesshagent.svg)](https://pypi.org/project/fakesshagent)
5
+
6
+A non-hardened implementation of the SSH agent protocol, for use in testing.
7
+
8
+`fakesshagent` was originally written to test [`derivepassphrase`][DERIVEPASSPHRASE]'s SSH agent interactions.
9
+As such, `fakesshagent` also registers as an abstract SSH agent socket provider for [`derivepassphrase`][DERIVEPASSPHRASE].
10
+(The implementation is based on memory buffers, not external I/O with sockets.)
11
+
12
+In this first release, `fakesshagent` provides exactly the operations needed for use with `derivepassphrase`:
13
+listing available keys, and deterministically signing a fixed message (if possible with this key type), for a set of known test keys.
14
+See the wishlist for planned features of subsequent releases.
15
+
16
+[DERIVEPASSPHRASE]: https://the13thletter.info/derivepassphrase/
17
+
18
+-----
19
+
20
+## Installation
21
+
22
+`fakesshagent` is a pure Python package, and may be easily installed with any `pip`-compatible Python package manager such as `pip`, `pipx`, or `uv`.
23
+
24
+```console
25
+pip install fakesshagent
26
+```
27
+
28
+`fakesshagent` requires Python 3.9 or higher as well as the [typing-extensions package][TYPING_EXTENSIONS] and the [`derivepassphrase-sshagentsocketprovider` package][SASP] for its core functionality and programmatic interface.
29
+
30
+[TYPING_EXTENSIONS]: https://pypi.org/project/typing-extensions/
31
+[SASP]: https://pypi.org/project/derivepassphrase-sshagentsocketprovider/
32
+
33
+## License
34
+
35
+`fakesshagent` is distributed under the terms of the [zlib/libpng](https://spdx.org/licenses/Zlib.html) license.
... ...
@@ -0,0 +1,12 @@
1
+{% extends "base.html" %}
2
+{% block analytics %}{% endblock %}
3
+{% block config %}{% endblock %}
4
+{% block libs %}{% endblock %}
5
+{% block scripts %}{% endblock %}
6
+{% block fonts %}
7
+  {% if config.theme.font != false %}
8
+    {% set text = config.theme.font.get("text", "Roboto") %}
9
+    {% set code = config.theme.font.get("code", "Roboto Mono") %}
10
+        <style>:root{--md-text-font:"{{ text }}";--md-code-font:"{{ code }}"}</style>
11
+  {% endif %}
12
+{% endblock %}
... ...
@@ -0,0 +1,28 @@
1
+{#-
2
+  This file is based on an automatically generated file from MkDocs-Material.
3
+
4
+  Changes:
5
+
6
+    - Some entries below the path docs/wishlist/... are actually from
7
+    a submodule pointing to this same repository, but at the "wishlist"
8
+    branch.
9
+-#}
10
+{% if page.edit_url %}
11
+  {% if "content.action.edit" in features %}
12
+    <a href="{{ page.edit_url | replace('master/docs/wishlist/', 'wishlist/') }}" title="{{ lang.t('action.edit') }}" class="md-content__button md-icon">
13
+      {% set icon = config.theme.icon.edit or "material/file-edit-outline" %}
14
+      {% include ".icons/" ~ icon ~ ".svg" %}
15
+    </a>
16
+  {% endif %}
17
+  {% if "content.action.view" in features %}
18
+    {% if "/blob/" in page.edit_url %}
19
+      {% set part = "blob" %}
20
+    {% else %}
21
+      {% set part = "edit" %}
22
+    {% endif %}
23
+    <a href="{{ page.edit_url | replace(part, 'raw') | replace('master/docs/wishlist/', 'wishlist/') }}" title="{{ lang.t('action.view') }}" class="md-content__button md-icon">
24
+      {% set icon = config.theme.icon.view or "material/file-eye-outline" %}
25
+      {% include ".icons/" ~ icon ~ ".svg" %}
26
+    </a>
27
+  {% endif %}
28
+{% endif %}
... ...
@@ -0,0 +1,20 @@
1
+{#-
2
+  This file was automatically generated - do not edit
3
+-#}
4
+<div class="md-copyright">
5
+  {% if config.copyright %}
6
+    <div class="md-copyright__highlight">
7
+      {{ config.copyright }}
8
+    </div>
9
+  {% endif %}
10
+  {% if not config.extra.generator == false %}
11
+    Made with
12
+    <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
13
+      Material for MkDocs
14
+    </a>
15
+    and
16
+    <a href="https://mkdocstrings.github.io/python/" target="_blank" rel="noopener">
17
+        mkdocstrings-python
18
+    </a>
19
+  {% endif %}
20
+</div>
... ...
@@ -0,0 +1,15 @@
1
+{#-
2
+  This file was automatically generated - do not edit
3
+-#}
4
+<!doctype html>
5
+<html>
6
+  <head>
7
+    <meta charset="utf-8">
8
+    <meta name="viewport" content="width=device-width,initial-scale=1">
9
+    <title>{{ config.site_name }}</title>
10
+    <meta http-equiv="refresh" content="0;url={{ page.meta.location }}">
11
+  </head>
12
+  <body>
13
+    <div><a href="{{ page.meta.location | url }}">The document has moved.</a></div>
14
+  </body>
15
+</html>
... ...
@@ -0,0 +1,2 @@
1
+<!-- Originally downloaded from https://img.shields.io/badge/Keep-a-changelog-E05735?logoColor=fff&logo=keepachangelog&logoSize=auto on 2024-07-21. Used under CC0, as per https://github.com/badges/shields/blob/1e4c8f54d7743381e723a1ca3f79db7868c2f95e/LICENSE . -->
2
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="127.21161107182145" height="20" role="img" aria-label="Keep a changelog"><title>Keep a changelog</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="127.21161107182145" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#555"/><rect x="0" width="127.21161107182145" height="20" fill="#e05735"/><rect width="127.21161107182145" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><image x="5" y="3" width="14.21161107182145" height="14" xlink:href="data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjZmZmIiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyMy42NDI3NjMyMTAwOTQ4NjYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHRpdGxlPktlZXAgYSBDaGFuZ2Vsb2c8L3RpdGxlPjxwYXRoIGQ9Ik05LjgwMzEgMC4wMDAzQzkuMTA4MSAtMC4wMDQ3IDguNDEzMSAwLjA2MTMgNy43MjAxIDAuMjAxM0M1LjYzOTEgMC42MTgzIDMuOTc0MSAxLjY0OTMgMi43NzAxIDMuMjY2M0MxLjIzMjEgNS4zMzYzIDAuMzY3MSA3Ljg5MzMgMC4wNDkxIDExLjMxMzNDLTAuMDc1OSAxMi42NjYzIDAuMDU3MSAxNC4wMjQzIDAuMjMwMSAxNS41MjkzQzAuNDI4MyAxNy40MzczIDEuMTcyNyAxOS4yNDc3IDIuMzc0MSAyMC43NDMzQzMuMzA3MSAyMS45MDAzIDQuMzc4MSAyMi42NjAzIDUuNjQ2MSAyMy4wNjczQzYuNzI1MyAyMy40MTU2IDcuODQ4NiAyMy42MDg5IDguOTgyMSAyMy42NDEzQzkuMjQ3NiAyMy42NDcxIDkuNTEzMSAyMy42MzU4IDkuNzc3MSAyMy42MDczTDEwLjE5MzEgMjMuNTY2M0MxMC43Njc0IDIzLjUwOTYgMTEuMzQxMSAyMy40NDc2IDExLjkxNDEgMjMuMzgwM0MxNC40OTUgMjMuMDgyNyAxNy4wMDAxIDIyLjMxNzkgMTkuMzA3MSAyMS4xMjMzQzIxLjMxNDEgMjAuMDc1MyAyMi43MTcxIDE4LjUyOTMgMjMuNDc3MSAxNi41MjYzQzIzLjgzMTEgMTUuNTk2MyAyNC4wMDAxIDE0LjgxMzMgMjQuMDAwMSAxNC4wODEzQzI0IDEzLjgyNTkgMjMuOTc4NiAxMy41NzEgMjMuOTM2MSAxMy4zMTkzQzIzLjUzMTEgMTAuOTQwMyAyMi43MDExIDguODkxMyAyMS4zODExIDcuMDU1M0MxOS43NDcxIDQuNzc5MyAxOC4wOTMxIDMuMTc3MyAxNi4xNzkxIDIuMDEwM0MxMy45OTMxIDAuNjgxMyAxMS44OTAxIDAuMDE0MyA5LjgwMzEgMC4wMDEzWk05LjM2NDEgMS4wNjQzQzEwLjEzNDEgMS4wNDMzIDEwLjkyNDEgMS4xMjIzIDExLjc0MDEgMS4zMDEzSDExLjczOTFDMTQuNDY5MSAxLjg5ODMgMTYuODc2MSAzLjMwMzMgMTguODkzMSA1LjQ3NDNDMjEuMTgxMSA3LjkzNDMgMjIuNDg0MSAxMC41MTkzIDIyLjg4MTEgMTMuMzczM0MyMi45NTIxIDEzLjkwMTMgMjIuODY4MSAxNC40ODczIDIyLjc4NTEgMTQuOTk1M0MyMi4zOTcxIDE3LjMyMjMgMjEuMTIyMSAxOS4wNTgzIDE4Ljk5OTEgMjAuMTU1M0MxNi43NjA1IDIxLjMwNjIgMTQuMzMzMyAyMi4wNDUzIDExLjgzMzEgMjIuMzM3M0MxMS4yNzUxIDIyLjQwMzMgMTAuNzEzMSAyMi40NjAzIDEwLjA3NjEgMjIuNTI0M0w5Ljk4ODEgMjIuNTM0M0M5LjQzNjMgMjIuNTkxNSA4Ljg3OTUgMjIuNTgxNSA4LjMzMDEgMjIuNTA0M0M3LjY0MDEgMjIuNDA2MyA2Ljg1ODEgMjIuMjk0MyA2LjEwNjEgMjIuMTE1M0M0LjMzOTEgMjEuNjk1MyAzLjAzNzEgMjAuNDkzMyAyLjEyODEgMTguNDU3M0MxLjQ0NDEgMTYuOTE5MyAxLjA5MDEgMTUuMjEyMyAxLjA0NzEgMTMuMjI0M0MwLjk4NjUgMTAuOTA1MiAxLjM3NjcgOC41OTY1IDIuMTk2MSA2LjQyNjNDMi44MTYxIDQuNzg1MyAzLjcxMzEgMi45NzMzIDUuNjU4MSAyLjAwODNDNi44MDg3IDEuNDI0MSA4LjA3NDMgMS4xMDE3IDkuMzY0MSAxLjA2NDNaTTEwLjQ3MjEgMi41MzQzQzkuNzA5MyAyLjU1OTggOC45NTQ1IDIuNjk4IDguMjMyMSAyLjk0NDNDNi42MzIxIDMuNDY5MyA1LjUxNDEgNC4yNDgzIDQuNzA5MSA1LjM4MjNDMy4yMTAxIDcuNTAwMyAyLjQzNDEgOS45NjIzIDIuMzk5MSAxMi43MDczQzIuMzM3MiAxNC4yMTE0IDIuNjExNSAxNS43MTA1IDMuMjAyMSAxNy4wOTUzQzQuMjgzMSAxOS42NjQzIDYuMjU1MSAyMS4xNTkzIDguOTA5MSAyMS40MjEzQzExLjcwNTEgMjEuNjkxMyAxNC40NTIxIDIxLjA0MDMgMTcuMDg3MSAxOS40ODQzQzE4LjIwNjUgMTguODQyMiAxOS4xNTY3IDE3Ljk0MjcgMTkuODU5MSAxNi44NjAzQzIwLjYxMzIgMTUuNzEzNCAyMS4wMTg4IDE0LjM3MjcgMjEuMDI3MSAxMy4wMDAzQzIxLjAyNzEgMTAuODM4MyAyMC4wMTIxIDguNTMwMyAxNy45NzgxIDYuMTM4M0MxNy43NjIyIDUuODg0NiAxNy41MjY4IDUuNjQ4MyAxNy4yNzQxIDUuNDMxM0MxNC45NDExIDMuNDI1MyAxMi43MDcxIDIuNDY1MyAxMC40NzIxIDIuNTMzM1pNMTAuNTcxMSAzLjU2OTNDMTIuMDYzMSAzLjU0NzMgMTMuNTg2MSA0LjA0NDMgMTUuMjQzMSA1LjA3NzNDMTcuMDI2MSA2LjE5NDMgMTguNDA0MSA3Ljg0MDMgMTkuMzM0MSA5Ljk4NjNDMjAuMjIxMSAxMi4wMDYzIDIwLjIzOTEgMTMuOTI0MyAxOS4zOTIxIDE1LjY4MjNDMTguNzU5NCAxNi45NTc3IDE3Ljc0MjcgMTguMDAyNyAxNi40ODUxIDE4LjY3MDNDMTQuMjMyMSAxOS45MDIzIDEyLjE3MDEgMjAuNDQ2MyAxMC4wMjcxIDIwLjM4MzNIMTAuMDA1MUM4LjQ1NTEgMjAuNDQzMyA3LjExNTEgMjAuMDYzMyA1LjkwNzEgMTkuMjIwM0M1LjIwNzEgMTguNzMyMyA0LjY2NjEgMTguMDE4MyA0LjI1MjEgMTcuMDM3M0MzLjY3NzYgMTUuNjg1OSAzLjM4MTEgMTQuMjMyNyAzLjM4MDEgMTIuNzY0M0MzLjM5NzkgMTEuMDk0OSAzLjc0NTkgOS40NDU1IDQuNDA0MSA3LjkxMTNDNC42NTQgNy4zNDkzIDQuOTQ2MiA2LjgwNyA1LjI3ODEgNi4yODkzTDUuNDQxMSA2LjAxOTNDNi4xMTExIDQuOTExMyA3LjE2NjEgNC4zODAzIDguNTQ0MSAzLjkyMDNDOS4xOTggMy43MDA4IDkuODgxNiAzLjU4MjggMTAuNTcxMSAzLjU3MDNaTTEwLjYwMTEgNS4xODkzQzkuNDIxMSA1LjIyNjMgOC4zMjExIDUuNjU0MyA3LjMxMjEgNi40NzIzQzUuOTE3MSA3LjYwMjMgNS4wODIxIDkuMTkyMyA0LjY4MjEgMTEuNDY1M0M0LjM5MjcgMTMuMDM1NCA0LjYzMDQgMTQuNjU3MiA1LjM1ODEgMTYuMDc4M0M2LjQyMDEgMTguMTMyMyA4LjE1ODEgMTkuMTg4MyAxMC4zODQxIDE5LjEyODNIMTAuMzkyMUMxMy4zNTAxIDE4Ljk3MzMgMTUuNTAyMSAxNy44NzkzIDE2Ljk2NTEgMTUuNzgxM0MxNy42NDczIDE0LjgzNjEgMTguMDE3OCAxMy43MDE4IDE4LjAyNTEgMTIuNTM2M0MxOC4wMjMxIDExLjk4MTIgMTcuOTQxNiAxMS40MjkyIDE3Ljc4MzEgMTAuODk3M0MxNy4wNjMxIDguNDYyMyAxNS42MjIxIDYuNzUyMyAxMy40OTMxIDUuODEyM0MxMi40ODQxIDUuMzY3MyAxMS41MTkxIDUuMTYwMyAxMC42MDExIDUuMTg4M1pNMTAuOTEzMSA2LjI1MzNDMTEuNjgzMSA2LjI2MTMgMTIuNDYwMSA2LjQ0NDMgMTMuMjYzMSA2LjgwMzNDMTUuMTI1MSA3LjYzNjMgMTYuMzM2MSA5LjEzNTMgMTYuODYxMSAxMS4yNjUzQzE3LjM4NzEgMTMuMzk1MyAxNi41OTExIDE1LjQ3MDMgMTQuNzk1MSAxNi42ODgzQzEzLjMxNjEgMTcuNjg3MyAxMS44NjUxIDE4LjEzNTMgMTAuMzU3MSAxOC4wNTUzSDEwLjMxNzFDOS4zMTMxIDE4LjEzNTMgOC4zMjMxIDE3LjgyOTMgNy4zNzQxIDE3LjE0NTNDNi43ODIgMTYuNzEzNyA2LjMzMjcgMTYuMTE0NiA2LjA4NDEgMTUuNDI1M0M1Ljc0NzggMTQuNTU1MyA1LjU3MzYgMTMuNjMxIDUuNTcwMSAxMi42OTgzQzUuNTgzOSAxMS41MzIyIDUuODM4MiAxMC4zODE1IDYuMzE3MSA5LjMxODNDNi45OTMxIDcuNzYyMyA4LjA0MTEgNi44MTUzIDkuNTMyMSA2LjQyNTNDOS45OTIxIDYuMzA1MyAxMC40NTExIDYuMjQ3MyAxMC45MTIxIDYuMjUzM1pNOS40NTUxIDguNTc5M1YxNS40NjEzSDEwLjYxMDFWMTMuNTUzM0wxMi4yOTAxIDE1LjQ2MTNIMTMuNzUyMUwxMS40NzkxIDEyLjk2MTNMMTMuMzUwMSAxMS4wMDMzSDExLjg0MTFMMTAuNjEwMSAxMi4zNDAzVjguNTc5M1oiLz48L3N2Zz4="/><text aria-hidden="true" x="727.1161107182145" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="990">Keep a changelog</text><text x="727.1161107182145" y="140" transform="scale(.1)" fill="#fff" textLength="990">Keep a changelog</text></g></svg>
... ...
@@ -0,0 +1,2 @@
1
+<!-- Originally downloaded from https://img.shields.io/badge/SemVer-3F4551?logoColor=fff&logo=semver&logoSize=auto on 2024-07-21. Used under CC0, as per https://github.com/badges/shields/blob/1e4c8f54d7743381e723a1ca3f79db7868c2f95e/LICENSE . -->
2
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="71.00018479842694" height="20" role="img" aria-label="SemVer"><title>SemVer</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="71.00018479842694" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="0" height="20" fill="#555"/><rect x="0" width="71.00018479842694" height="20" fill="#3f4551"/><rect width="71.00018479842694" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><image x="5" y="3" width="14.00018479842695" height="14" xlink:href="data:image/svg+xml;base64,PHN2ZyBmaWxsPSJ3aGl0ZXNtb2tlIiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNC4wMDAzNzU5MjkyNjAxNTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHRpdGxlPlNlbVZlcjwvdGl0bGU+PHBhdGggZD0iTTAuMzU3IDkuMDI0NkMtMC41OTQzIDEyLjg0ODcgMC4zODExIDE2Ljg5NjUgMi45NyAxOS44Njc2QzUuNTU4IDIyLjgzNjkgOS40MzM5IDI0LjM1NCAxMy4zNSAyMy45MzA2QzIxLjExOCAyMy4yMjc2IDI2LjQzNiAxNC4xMzE2IDIyLjg2NyA3LjEzMDZDMjIuNDUxIDUuOTQwNiAyMC43OTcgNi43NjI2IDIwLjk2NCA3LjcyNjZDMjEuMjUxIDguNDI2NiAyMS40OSA5LjE0NzYgMjEuNjc3IDkuODgxNkMyMi41MzI1IDEzLjc3NTcgMjAuOTg5IDE3LjgwNTQgMTcuNzUxIDIwLjEzMTZDMTIuNzA2NiAyMy43NTI1IDUuNjE0OSAyMS45MjgyIDIuOTQ0IDE2LjMyMjZDMS4yMjY3IDEyLjcyNDMgMS44MTQ4IDguNDQ5NCA0LjQ0IDUuNDQ4NkM1LjcyNDEgMy45ODE0IDcuNDEwNCAyLjkyMyA5LjI5IDIuNDA0NkMxMS42Mjc4IDEuNzQzNSAxNC4xMjc0IDEuOTY4MiAxNi4zMSAzLjAzNTZDMTYuNDY2OCAzLjExNTIgMTYuNTM0NyAzLjMwMzEgMTYuNDY1IDMuNDY0NkwxMi41MDMgMTQuMDg0NkMxMi4zOTYgMTQuODk0NiAxMS44MTMgMTQuODcwNiAxMS43MDYgMTQuMDg0Nkw5LjMyNiA2LjcxNDZDOS4yMTY1IDYuMjkyMiA4LjkzNjYgNS45MzQ0IDguNTUzIDUuNzI2NkM3LjM2MyA1LjE2NjYgNS40NiA2LjM5MzYgNi4xNzQgNy44ODE2TDEwLjA4OCAxOC4zMjI2QzEwLjYxMiAxOS43MTU2IDExLjExMSAyMC4xNTY2IDEyLjE0NiAyMC4xNTY2UzEzLjY4MSAxOS43MTY2IDE0LjIwNCAxOC4zMjI2TDIwIDMuOTQwNkMyMC4xNTU5IDMuNTA2IDIwLjAwNTQgMy4wMjA3IDE5LjYzMSAyLjc1MDZDMTMuMSAtMi45MDY0IDIuMzIgMC42NDE2IDAuMzU3IDkuMDIzNloiLz48L3N2Zz4="/><text aria-hidden="true" x="445.0018479842695" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">SemVer</text><text x="445.0018479842695" y="140" transform="scale(.1)" fill="#fff" textLength="430">SemVer</text></g></svg>
... ...
@@ -0,0 +1,53 @@
1
+# Changelog for `fakesshagent`
2
+
3
+[![Keeping a changelog][CHANGELOG_BADGE]][KEEP_A_CHANGELOG]
4
+[![Using Semantic Versioning][SEMVER_BADGE]][SEMANTIC_VERSIONING]
5
+
6
+  [CHANGELOG_BADGE]: Keep_a_changelog-E05735.svg
7
+  [SEMVER_BADGE]: SemVer-3F4551.svg
8
+  [KEEP_A_CHANGELOG]: https://keepachangelog.com/en/1.1.0/ 'Keeping a changelog'
9
+  [SEMANTIC_VERSIONING]: https://semver.org/ 'Using Semantic Versioning'
10
+
11
+??? info "Definition: the <q>public API</q> of `fakesshagent`"
12
+
13
+    The <dfn>public API</dfn>, as defined by Semantic Versioning, is
14
+    outlined in the Reference section: the set of documented
15
+    modules, classes, attributes, methods, functions and function
16
+    parameters, and the documented behavior, options and arguments of the
17
+    command-line tools.
18
+
19
+    Certain **exceptions to this rule** are explicitly and prominently
20
+    marked as implementation details/not part of the public API.
21
+
22
+??? info "Interpretation of the version number"
23
+
24
+    The terminology <b>major</b>, <b>minor</b> and <b>patch</b> follows the
25
+    Semantic Versioning and Keep a Changelog definitions.
26
+
27
+      * For version numbers with major version zero, *any* new release may
28
+        effectively constitute a new <b>major</b> release.
29
+      * For version numbers with major version one or higher,
30
+          * <b>Fixed</b> entries justify a <b>patch</b> release;
31
+          * <b>Added</b> and <b>Deprecated</b> entries justify
32
+            a <b>minor</b> release;
33
+          * <b>Changed</b> and <b>Removed</b> entries justify a <b>major</b>
34
+            release.
35
+          * <b>Security</b> can justify any type of release; if <b>major</b>
36
+            or <b>minor</b>, these are accompanied by corresponding entries
37
+            of the respective types above.
38
+
39
+!!! abstract inline end "Legend: symbols"
40
+
41
+    <ul style="list-style-type: none;">
42
+    <li>:material-bug: --- a related bug entry from the wishlist page
43
+    <li>:material-file-document-plus-outline: --- a related wishlist entry from the wishlist page
44
+    </ul>
45
+
46
+<aside markdown><small>
47
+(All entries are from the perspective of a user of the program or the API.
48
+As an exception, entries partaining to developers of `fakesshagent` are
49
+specifically marked as such.)
50
+</small></aside>
51
+
52
+<!-- scriv changelog start -->
53
+
... ...
@@ -0,0 +1,26 @@
1
+# How to comply with the "altered versions" clause of the license
2
+
3
+!!! abstract inline end "Local version identifiers reserved by upstream `fakesshagent`"
4
+
5
+    *   `jvm` -- Indicates a build of `fakesshagent` running on the Java Virtual Machine.
6
+
7
+**Short answer:** change the package name and/or include a [PEP 440][] "local version identifier" in the version number.
8
+If `fakesshagent` ever rebrands, this applies to rebranded names too.
9
+We try not to clash with anyone else, and will yank our offending releases if we do.
10
+
11
+**Long answer:** We, upstream `fakesshagent`, reserve the name `fakesshagent` and certain version numbers for ourselves.
12
+Specifically, our version numbers adhere to [PEP 440][] (or newer revisions) and generally do not include a "local version identifier" (except for the reserved ones mentioned in the info box).
13
+To mark an altered version, we thus recommend that you change the software package name `fakesshagent`, or use a version number with a different local version identifier.
14
+If we (upstream) decide to use a new local version identifier, we will avoid all clashing local version identifiers we are aware of, and if informed of a clashing local version identifier after our release, will yank our offending version(s).
15
+
16
+Should we (upstream) change the package name, we shall apply the same guidelines and checks concerning local version identifiers to the new package name.
17
+A change of package name does *not* by itself imply permission to use the old package name for future releases of altered versions without marking them.
18
+
19
+---
20
+
21
+See also [the zlib project's take on how to mark altered versions (question #24)][ZLIB_FAQ].
22
+Like them, we recommend keeping our upstream Changelog (up to the point where you introduced modifications) and describing your modifications both there and in the README, in the appropriate level of detail.
23
+We also request (but do not require) that you provide clear instructions in the README (and potentially other suitable places) on where and how to report problems that stem from your modifications, not from the upstream software package.
24
+
25
+[PEP 440]: https://peps.python.org/pep-0440/
26
+[ZLIB_FAQ]: https://github.com/madler/zlib/blob/v1.3.1/FAQ
... ...
@@ -0,0 +1,8 @@
1
+---
2
+title: Explanation overview
3
+---
4
+
5
+* [How to comply with the "altered versions" clause of the
6
+  license][FAQ_ALTERED_VERSIONS]
7
+
8
+[FAQ_ALTERED_VERSIONS]: faq-altered-versions.md
... ...
@@ -0,0 +1 @@
1
+--8<-- "README.md"
... ...
@@ -0,0 +1,35 @@
1
+/* Indentation. */
2
+div.doc-contents:not(.first) {
3
+  padding-left: 25px;
4
+  border-left: .05rem solid var(--md-typeset-table-color);
5
+}
6
+
7
+/* Mark external links as such. */
8
+div[data-md-component="content"] a:not(.md-icon):is([href^="https://"], [href^="http://"], [href^="//"])::after,
9
+a.external::after,
10
+a.autorefs-external::after {
11
+  /* https://primer.style/octicons/arrow-up-right-24 */
12
+  mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18.25 15.5a.75.75 0 00.75-.75v-9a.75.75 0 00-.75-.75h-9a.75.75 0 000 1.5h7.19L6.22 16.72a.75.75 0 101.06 1.06L17.5 7.56v7.19c0 .414.336.75.75.75z"></path></svg>');
13
+  -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18.25 15.5a.75.75 0 00.75-.75v-9a.75.75 0 00-.75-.75h-9a.75.75 0 000 1.5h7.19L6.22 16.72a.75.75 0 101.06 1.06L17.5 7.56v7.19c0 .414.336.75.75.75z"></path></svg>');
14
+  content: ' ';
15
+
16
+  display: inline-block;
17
+  vertical-align: middle;
18
+  position: relative;
19
+
20
+  height: 1em;
21
+  width: 1em;
22
+  background-color: currentColor;
23
+}
24
+
25
+/* ... but don't mark badges as external */
26
+div[data-md-component="content"] a:not(.md-icon):is([href^="https://"], [href^="http://"], [href^="//"]):has(img[src^="https://img.shields.io/"], img[src="Keep_a_changelog-E05735.svg"], img[src="../Keep_a_changelog-E05735.svg"], img[src="SemVer-3F4551.svg"], img[src="../SemVer-3F4551.svg"])::after {
27
+    content: '';
28
+    width: 0em;
29
+    height: 0em;
30
+}
31
+
32
+a.external:hover::after,
33
+a.autorefs-external:hover::after {
34
+  background-color: var(--md-accent-fg-color);
35
+}
... ...
@@ -0,0 +1,87 @@
1
+# Compatibility of `fakesshagent` with different Python versions
2
+
3
+## Python 3.15  {#py3.15}
4
+
5
+Currently supported without any known issues.
6
+
7
+## Python 3.14  {#py3.14}
8
+
9
+Currently supported without any known issues.
10
+
11
+### After end-of-life {#after-eol-py3.14}
12
+
13
+After Python 3.14 reaches end-of-life, future `fakesshagent` versions may make use of the following Python functionality:
14
+
15
+  * lazy imports
16
+  * the `frozendict` type
17
+
18
+The declared minimum required Python version will then be updated accordingly.
19
+
20
+## Python 3.13  {#py3.13}
21
+
22
+Currently supported without any known issues.
23
+
24
+### After end-of-life {#after-eol-py3.13}
25
+
26
+After Python 3.13 reaches end-of-life, future `fakesshagent` versions may make use of the following Python functionality:
27
+
28
+  * template strings
29
+
30
+The declared minimum required Python version will then be updated accordingly.
31
+
32
+## Python 3.12  {#py3.12}
33
+
34
+Currently supported without any known issues.
35
+
36
+### After end-of-life {#after-eol-py3.12}
37
+
38
+Python 3.13 does not offer significantly different functionality than Python 3.12.
39
+There are thus no plans to make use of additional functionality once Python 3.12 reaches end-of-life.
40
+
41
+## Python 3.11  {#py3.11}
42
+
43
+Currently supported without any known issues.
44
+
45
+### After end-of-life {#after-eol-py3.11}
46
+
47
+After Python 3.11 reaches end-of-life, future `fakesshagent` versions may make use of the following Python functionality:
48
+
49
+  * complex expressions in f-strings
50
+  * type parameter syntax and the `type` statement
51
+
52
+The declared minimum required Python version will then be updated accordingly.
53
+
54
+## Python 3.10  {#py3.10}
55
+
56
+Currently supported without any known issues.
57
+
58
+### After end-of-life {#after-eol-py3.10}
59
+
60
+After Python 3.10 reaches end-of-life, future `fakesshagent` versions may make use of the following Python functionality:
61
+
62
+  * exception groups
63
+  * exception notes
64
+  * [`contextlib.chdir`][]
65
+
66
+The declared minimum required Python version will be then updated accordingly.
67
+
68
+## Python 3.9  {#py3.9}
69
+
70
+End-of-life since October 2025, but still supported at least until `fakesshagent` v1.0, without any known issues.
71
+
72
+### After end-of-life {#after-eol-py3.9}
73
+
74
+After Python 3.9 reaches end-of-life, future `fakesshagent` versions may make use of the following Python functionality:
75
+
76
+  * structural pattern matching (`match`/`case` blocks)
77
+  * parenthesized `with` statements
78
+
79
+The declared minimum required Python version will be then updated accordingly.
80
+
81
+## Python 3.8 and below  {#py3.8-and-below}
82
+
83
+These versions were never explicitly supported, neither in CPython nor in PyPy.
84
+
85
+## PyPy
86
+
87
+As per the respective CPython version above.
... ...
@@ -0,0 +1,4 @@
1
+# Upgrade notes for `fakesshagent`
2
+
3
+(Nothing yet.
4
+Once there is something, this will be listed here, oldest version first.)
... ...
@@ -0,0 +1 @@
1
+Subproject commit 892cce5eece34e7f06e98348540f8491d94d69fe
... ...
@@ -0,0 +1,94 @@
1
+p:has(+ table#bug-summary) {
2
+    margin: 0em;
3
+}
4
+table#bug-summary {
5
+    width: 100%;
6
+    border: none;
7
+}
8
+table#bug-summary + p {
9
+    margin: 0em;
10
+}
11
+table#bug-summary th, table#bug-summary td {
12
+    padding: 0.25em;
13
+    border: none;
14
+}
15
+table#bug-summary td:first-of-type {
16
+    padding-right: 1.5em;
17
+}
18
+table#bug-summary td:first-of-type > a:has(+ a) {
19
+    margin-right: 0.5em;
20
+}
21
+
22
+a.scheduled-next {
23
+    font-weight: bold;
24
+}
25
+
26
+a.scheduled {
27
+    font-style: oblique;
28
+}
29
+
30
+a.unscheduled {
31
+    opacity: 0.75;
32
+}
33
+a.fixed {
34
+    font-weight: normal;
35
+    font-style: normal;
36
+    text-decoration: line-through;
37
+}
38
+
39
+.md-typeset div.admonition.abstract .emojione,
40
+.md-typeset div.admonition.abstract .gemoji,
41
+.md-typeset div.admonition.abstract .twemoji,
42
+.md-typeset a[href^="../wishlist/"] .emojione,
43
+.md-typeset a[href^="../wishlist/"] .gemoji,
44
+.md-typeset a[href^="../wishlist/"] .twemoji,
45
+.md-typeset a[href^="wishlist/"] .emojione,
46
+.md-typeset a[href^="wishlist/"] .gemoji,
47
+.md-typeset a[href^="wishlist/"] .twemoji {
48
+    vertical-align: middle;
49
+}
50
+
51
+:root {
52
+    --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14 12h-4v-2h4m0 6h-4v-2h4m6-6h-2.81a6 6 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6 6 0 0 0-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20z"/></svg>');
53
+    --md-admonition-icon--bug-success: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 7h-2.81c-.45-.8-1.07-1.5-1.82-2L16 3.41 14.59 2l-2.17 2.17a6 6 0 0 0-2.83 0L7.41 2 6 3.41 7.62 5c-.75.5-1.36 1.21-1.81 2H3v2h2.09c-.06.33-.09.66-.09 1v1H3v2h2v1c0 .34.03.67.09 1H3v2h2.81A6 6 0 0 0 13 19.65V19c0-2.54 1.61-4.8 4-5.65V13h2v-2h-2v-1c0-.34-.03-.67-.09-1H19zm-6 8H9v-2h4zm0-4H9V9h4zm4.75 11.16-2.75-3L16.16 18l1.59 1.59L21.34 16l1.16 1.41z"/></svg>');
54
+    --md-admonition-icon--wish: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M23 18h-3v-3h-2v3h-3v2h3v3h2v-3h3M6 2a2 2 0 0 0-2 2v16c0 1.11.89 2 2 2h7.81c-.36-.62-.61-1.3-.73-2H6V4h7v5h5v4.08c.33-.05.67-.08 1-.08.34 0 .67.03 1 .08V8l-6-6M8 12v2h8v-2m-8 4v2h5v-2Z"/></svg>');
55
+    --md-admonition-icon--wish-success: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m23.5 17-5 5-3.5-3.5 1.5-1.5 2 2 3.5-3.5zM6 2a2 2 0 0 0-2 2v16c0 1.11.89 2 2 2h7.81c-.36-.62-.61-1.3-.73-2H6V4h7v5h5v4.08c.33-.05.67-.08 1-.08.34 0 .67.03 1 .08V8l-6-6M8 12v2h8v-2m-8 4v2h5v-2Z"/></svg>');
56
+}
57
+.md-typeset .admonition.wish,
58
+.md-typeset details.wish {
59
+    border-color: #888888;
60
+}
61
+.md-typeset .wish > .admonition-title,
62
+.md-typeset .wish > summary {
63
+    background-color: #8888881a;
64
+}
65
+.md-typeset .wish > .admonition-title::before,
66
+.md-typeset .wish > summary::before {
67
+    background-color: #888888;
68
+    -webkit-mask-image: var(--md-admonition-icon--wish);
69
+            mask-image: var(--md-admonition-icon--wish);
70
+}
71
+.md-typeset .admonition.bug-success,
72
+.md-typeset details.bug-success,
73
+.md-typeset .admonition.wish-success,
74
+.md-typeset details.wish-success {
75
+    border-color: #00c853;
76
+}
77
+.md-typeset .bug-success > .admonition-title,
78
+.md-typeset .bug-success > summary,
79
+.md-typeset .wish-success > .admonition-title,
80
+.md-typeset .wish-success > summary {
81
+    background-color: #00c8531a;
82
+}
83
+.md-typeset .bug-success > .admonition-title::before,
84
+.md-typeset .bug-success > summary::before {
85
+    background-color: #00c853;
86
+    -webkit-mask-image: var(--md-admonition-icon--bug-success);
87
+            mask-image: var(--md-admonition-icon--bug-success);
88
+}
89
+.md-typeset .wish-success > .admonition-title::before,
90
+.md-typeset .wish-success > summary::before {
91
+    background-color: #00c853;
92
+    -webkit-mask-image: var(--md-admonition-icon--wish-success);
93
+            mask-image: var(--md-admonition-icon--wish-success);
94
+}
... ...
@@ -0,0 +1,148 @@
1
+site_name: fakesshagent
2
+site_url: 'https://the13thletter.info/fakesshagent/'
3
+repo_url: 'https://git.schokokeks.org/fakesshagent.git'
4
+edit_uri: 'raw/master/docs/'
5
+repo_name: the-13th-letter/fakesshagent
6
+site_description: A non-hardened implementation of the SSH agent protocol, for use in testing.
7
+site_author: Marco Ricci
8
+copyright: Copyright &copy; 2026 Marco Ricci (the-13th-letter)
9
+
10
+remote_branch: documentation-tree
11
+remote_name: '.'
12
+
13
+docs_dir: docs
14
+site_dir: html
15
+extra_css:
16
+  - mkdocstrings_recommended_styles.css
17
+  - wishlist_styling.css
18
+
19
+theme:
20
+  name: "material"
21
+  #font: false
22
+  font:
23
+    text: Noto Sans
24
+    code: Noto Mono
25
+    #code: Inconsolata
26
+  #palette:
27
+  #  - media: "(prefers-color-scheme)"
28
+  #    toggle:
29
+  #      icon: material/brightness-auto
30
+  #      name: Switch to light mode
31
+  #  - media: "(prefers-color-scheme: light)"
32
+  #    scheme: default
33
+  #    toggle:
34
+  #      icon: material/brightness-7
35
+  #      name: Switch to dark mode
36
+  #  - media: "(prefers-color-scheme: dark)"
37
+  #    scheme: slate
38
+  #    toggle:
39
+  #      icon: material/brightness-4
40
+  #      name: Switch to system preference
41
+  icon:
42
+    logo: material/library
43
+    repo: fontawesome/brands/git-alt
44
+  features:
45
+    - content.action.view
46
+    - content.code.annotate
47
+    - navigation.tracking
48
+    - navigation.tabs
49
+    - navigation.sections
50
+    - navigation.indexes
51
+    - navigation.footer
52
+  custom_dir: docs-overrides
53
+
54
+plugins:
55
+  offline:
56
+    enabled: false  # Use mkdocs_offline.yml for offline versions instead!
57
+  #privacy:
58
+  #  enabled: true
59
+  mkdocstrings:
60
+    handlers:
61
+      python:
62
+        inventories:
63
+          - 'https://docs.python.org/3/objects.inv'
64
+          - 'https://typing-extensions.readthedocs.io/en/latest/objects.inv'
65
+          #- 'https://cryptography.readthedocs.io/en/latest/objects.inv'
66
+          #- 'https://hypothesis.readthedocs.io/en/latest/objects.inv'
67
+        options:
68
+          docstring_options:
69
+            ignore_init_summary: true
70
+            returns_multiple_items: false
71
+          merge_init_into_class: true
72
+          show_source: false
73
+          heading_level: 2
74
+          show_object_full_path: false
75
+          show_root_members_full_path: false
76
+          show_root_heading: true
77
+          show_symbol_type_heading: true
78
+          show_symbol_type_toc: true
79
+          members_order: 'source'
80
+          show_signature_annotations: true
81
+          separate_signature: true
82
+          signature_crossrefs: true
83
+          unwrap_annotated: true
84
+          scoped_crossrefs: true
85
+        paths:
86
+          - src
87
+          - .
88
+  mike:
89
+    alias_type: symlink
90
+    canonical_version: '0.x'
91
+    version_selector: false
92
+
93
+nav:
94
+  - Overview: index.md
95
+  #- Tutorials & Examples:
96
+  #  - tutorials/index.md
97
+  #- How-Tos:
98
+  #  - how-tos/index.md
99
+  #- Reference:
100
+  #  - reference/index.md
101
+  - Design & Background:
102
+    - explanation/index.md
103
+    - '"altered versions" license requirement': explanation/faq-altered-versions.md
104
+  - Changelog:
105
+    - Changelog: changelog.md
106
+    - Upgrade notes: upgrade-notes.md
107
+    - Python compatibility: pycompatibility.md
108
+  - Wishlist:
109
+    - wishlist/index.md
110
+not_in_nav: |
111
+  wishlist/*.md
112
+  !wishlist/index.md
113
+draft_docs: |
114
+  changelog.d
115
+  _future.md
116
+  reference/tests.*
117
+  _release_checklist.md
118
+
119
+markdown_extensions:
120
+  - abbr
121
+  - attr_list
122
+  - def_list
123
+  - footnotes
124
+  - md_in_html
125
+  - admonition
126
+  - smarty
127
+  - toc:
128
+      permalink: true
129
+  - pymdownx.caret
130
+  - pymdownx.details
131
+  - pymdownx.emoji:
132
+      emoji_index: !!python/name:material.extensions.emoji.twemoji
133
+      emoji_generator: !!python/name:material.extensions.emoji.to_svg
134
+  - pymdownx.snippets:
135
+      base_path:
136
+        - 'docs'
137
+        - '.'
138
+      check_paths: true
139
+  - pymdownx.superfences
140
+  - pymdownx.tabbed:
141
+      alternate_style: true
142
+
143
+extra:
144
+  version:
145
+    provider: mike
146
+    default:
147
+      - latest
148
+      - stable
... ...
@@ -0,0 +1,16 @@
1
+INHERIT: mkdocs_offline.yml
2
+
3
+draft_docs: ''
4
+not_in_nav: |
5
+  changelog.d
6
+  wishlist/*.md
7
+  !wishlist/index.md
8
+
9
+# nav: ...
10
+
11
+plugins:
12
+  mkdocstrings:
13
+    handlers:
14
+      python:
15
+        options:
16
+          filters: []
... ...
@@ -0,0 +1,14 @@
1
+INHERIT: mkdocs.yml
2
+
3
+draft_docs: |
4
+  _future.md
5
+  reference/tests.*
6
+  _release_checklist.md
7
+not_in_nav: |
8
+  changelog.d
9
+  wishlist/*.md
10
+  !wishlist/index.md
11
+
12
+plugins:
13
+  offline:
14
+    enabled: true
... ...
@@ -0,0 +1,478 @@
1
+[build-system]
2
+requires = ["hatchling"]
3
+build-backend = "hatchling.build"
4
+
5
+[project]
6
+name = "fakesshagent"
7
+description = "A non-hardened implementation of the SSH agent protocol, for use in testing."
8
+readme = "README.md"
9
+version = "0.1a1"
10
+requires-python = ">= 3.9"
11
+license = "Zlib"
12
+keywords = []
13
+authors = [
14
+  { name = "Marco Ricci", email = "software@the13thletter.info" },
15
+]
16
+classifiers = [
17
+    "Development Status :: 4 - Beta",
18
+    "Environment :: Console",
19
+    "Operating System :: OS Independent",
20
+    "Programming Language :: Python :: 3",
21
+    "Programming Language :: Python :: 3.9",
22
+    "Programming Language :: Python :: 3.10",
23
+    "Programming Language :: Python :: 3.11",
24
+    "Programming Language :: Python :: 3.12",
25
+    "Programming Language :: Python :: 3.13",
26
+    "Programming Language :: Python :: 3.14",
27
+    "Programming Language :: Python :: 3.15",
28
+    "Programming Language :: Python :: Implementation :: CPython",
29
+    "Programming Language :: Python :: Implementation :: PyPy",
30
+    "Topic :: Software Development :: Testing",
31
+    "Typing :: Typed",
32
+]
33
+dependencies = [
34
+    "derivepassphrase-sshagentsocketprovider >= 1.0",
35
+    "typing_extensions",
36
+]
37
+
38
+[project.optional-dependencies]
39
+dev = [
40
+    # dev - developers' setup
41
+    #
42
+    # Development uses the hatch build system, to isolate all tools in
43
+    # their own virtual environment...
44
+    "hatch >= 1.10",
45
+    # ...but it is still sensible to have most static analysis tools
46
+    # available in the main virtual environment as well, so that your
47
+    # editor/IDE setup can access them as well.
48
+    "mypy >= 1.0",
49
+    "ruff >= 0.16.0",
50
+    # Test dependencies should be installed as well, to have static
51
+    # analysis in the test suite working too.
52
+    #
53
+    # `packaging` is needed because the test suite compares version
54
+    # numbers.
55
+    #
56
+    # `pytest-xdist` is used by `hatch test -p`, but for the `psutil`
57
+    # extra, the `psutil` package provides no wheels for PyPy.  Users on
58
+    # The Annoying OS are especially screwed if they lack the admin
59
+    # priviledges necessary to install the developers' tools.  So skip
60
+    # the extra, and accept that for PyPy environments, `hatch test -p`
61
+    # will not be able to distinguish logical and physical CPU core
62
+    # counts.
63
+    #
64
+    # We use `hypothesis.is_hypothesis_test`, which was introduced in
65
+    # 6.131.0, and `exc_type` for `pytest.importorskip`, which was
66
+    # introduced in 8.2.0.
67
+    "coverage[toml] >= 7.4",
68
+    "coverage-enable-subprocess >= 1.0",
69
+    "hypothesis >= 6.131.0",
70
+    "packaging",
71
+    "pytest >= 8.2",
72
+    "pytest-randomly >= 3.15",
73
+    "pytest-xdist >= 3.6.0",
74
+    'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"',
75
+    # `pytest` 8.4 and higher, and `hypothesis` versions 6.130.13
76
+    # through 6.135.1, use syntax only properly available on Python 3.10
77
+    # and later: `with (x as y, a as b)`.  However, those versions are
78
+    # not marked as incompatible with Python 3.9.
79
+    'pytest < 8.4 ; python_version < "3.10"',
80
+    'hypothesis != 6.130.13, != 6.131.*, != 6.132.*, != 6.133.*, != 6.134.*, != 6.135.0, != 6.135.1 ; python_version < "3.10"',
81
+    # `hypothesis` requires a Rust toolchain since version 6.156, and
82
+    # publishes wheels for PyPy 3.11 only. PyO3 version 0.27 also dropped
83
+    # support for PyPy 3.9 and 3.10, and cannot be overridden within
84
+    # pyproject.toml.
85
+    'hypothesis < 6.156 ; platform_python_implementation == "PyPy" and python_version < "3.11"',
86
+]
87
+
88
+[project.urls]
89
+Documentation = "https://the13thletter.info/fakesshagent/"
90
+Issues = "https://the13thletter.info/fakesshagent/latest/wishlist/"
91
+Source = "https://git.schokokeks.org/fakesshagent.git"
92
+
93
+[project.entry-points."derivepassphrase.ssh_agent_socket_providers"]
94
+stub_agent = "fakesshagent.machinery: STUB_AGENT_ENTRY_POINT"
95
+stub_agent_with_address = "fakesshagent.machinery: STUB_AGENT_WITH_ADDRESS_ENTRY_POINT"
96
+stub_agent_with_address_and_deterministic_dsa = "fakesshagent.machinery: STUB_AGENT_WITH_ADDRESS_AND_DETERMINISTIC_DSA_ENTRY_POINT"
97
+
98
+[tool.coverage.html]
99
+directory = "html/coverage"
100
+
101
+[tool.coverage.paths]
102
+src = ["src"]
103
+tests = ["tests"]
104
+
105
+[tool.coverage.report]
106
+skip_covered = false
107
+skip_empty = true
108
+precision = 3
109
+partial_branches = [
110
+    'pragma: no branch',
111
+]
112
+exclude_also = [
113
+    "if __name__ == .__main__.:",
114
+    'if (?:typing\.)?TYPE_CHECKING:',
115
+    "raise AssertionError",
116
+    "raise NotImplementedError",
117
+    'assert False',
118
+    '(?:typing\.)?assert_never\(',
119
+    '@overload',
120
+    'class .*\(Protocol\):',
121
+    'pytest\.fail\(',
122
+    '@(?:(?:pytest\.)?mark\.)?xfail\(',
123
+    'class _Omitted:',
124
+    'class _DummyModule:',
125
+]
126
+
127
+# We use a bunch of "codes", similar to the "type: ignore" comments, as
128
+# shorthand notation for why we are excluding a certain piece of code
129
+# manually from coverage, instead of writing a verbose explanation at each
130
+# call site:
131
+#
132
+# "external-api" and "unused": The code is defined by an external API not
133
+# under our control, or it implements an external interface, and some of the
134
+# branches of that code we do not yet use ("unused") or don't ever intend to
135
+# use ("external-api").  In particular, in production and in testing use, we
136
+# do not expect to hit these code branches unless there are errors in our
137
+# code.
138
+#
139
+# "external": Whether or not we hit this code branch is non-deterministic,
140
+# and dependent on external factors that are difficult or impossible for us
141
+# to control, predict, or simulate.  We therefore categorically exclude all
142
+# branches from coverage.  (Typically though, one particular branch *will*
143
+# actually run.  We just don't know ahead of time which one.)  (While this
144
+# applies to "choice of operating system" and "installed Python/library
145
+# version" as well, these cases are usually tagged with more specific codes,
146
+# or explained in long-form commentary.)
147
+#
148
+# "failsafe": Akin to assertions, these are used to catch "impossible"
149
+# situations and to still fail in a controlled manner.  We do not expect to
150
+# hit these code branches during production or testing.
151
+#
152
+# "debug", "internal" and "interactive": These code branches are not
153
+# intended to be called by the general public ("debug", "internal"), or they
154
+# are only intended to be called interactively ("interactive").  We do not
155
+# expect to hit these code branches during production or testing.
156
+
157
+[tool.coverage.run]
158
+source_pkgs = ["fakesshagent", "tests"]
159
+branch = true
160
+parallel = true
161
+omit = [
162
+  "__main__.py",
163
+]
164
+dynamic_context = 'test_function'
165
+concurrency = ['thread', 'multiprocessing']
166
+sigterm = true
167
+relative_files = true
168
+
169
+[tool.hatch.build.targets.sdist]
170
+exclude = [
171
+    'docs/changelog.d/*.md',
172
+]
173
+
174
+[tool.hatch.env]
175
+requires = [
176
+    "hatch-mkdocs",
177
+]
178
+
179
+[tool.hatch.env.collectors.mkdocs.docs]
180
+path = "mkdocs.yml"
181
+
182
+[tool.hatch.envs.docs]
183
+extra-dependencies = [
184
+    # Our documentation uses formatted function signatures (i.e. with
185
+    # formatted type annotations), which requires `black`.
186
+    "black",
187
+    # We actually use functionality from MkDocs 1.6, so ensure the
188
+    # version is at least this high.
189
+    "mkdocs >= 1.6",
190
+    # Our changelog is assembled from singular entries, orchestrated by
191
+    # `scriv`.
192
+    "scriv >= 1.4",
193
+]
194
+detached = false
195
+
196
+[tool.hatch.envs.hatch-check-code]
197
+config-path = "/dev/null"
198
+dependencies = [
199
+    # Override the default dependencies, lest the ruff requirements
200
+    # conflict.
201
+]
202
+features = [
203
+    "dev",
204
+]
205
+[tool.hatch.envs.hatch-check-fmt]
206
+config-path = "/dev/null"
207
+dependencies = [
208
+    # Override the default dependencies, lest the ruff requirements
209
+    # conflict.
210
+]
211
+features = [
212
+    "dev",
213
+]
214
+[tool.hatch.envs.hatch-check-types]
215
+config-path = "/dev/null"
216
+dependencies = [
217
+    # Override the default dependencies, lest the ruff requirements
218
+    # conflict.
219
+    "pyrefly >= 1.0.0",
220
+]
221
+features = [
222
+    "dev",
223
+]
224
+
225
+[tool.hatch.envs.hatch-test]
226
+default-args = ['src', 'tests']
227
+dependencies = [
228
+    "coverage[toml] >= 7.4",
229
+    "coverage-enable-subprocess >= 1.0",
230
+    "hypothesis >= 6.131.0",
231
+    "packaging",
232
+    "pytest >= 8.2",
233
+    "pytest-randomly >= 3.15",
234
+    "pytest-xdist >= 3.6.0",
235
+    'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"',
236
+    'pytest < 8.4 ; python_version < "3.10"',
237
+    'hypothesis != 6.130.13, != 6.131.*, != 6.132.*, != 6.133.*, != 6.134.*, != 6.135.0, != 6.135.1 ; python_version < "3.10"',
238
+    'hypothesis < 6.156 ; platform_python_implementation == "PyPy" and python_version < "3.11"',
239
+]
240
+matrix-name-format = '{variable}_{value}'
241
+
242
+[[tool.hatch.envs.hatch-test.matrix]]
243
+python = ["3.15", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "pypy3.11", "pypy3.10", "pypy3.9"]
244
+parser-version = ["PEG"]
245
+
246
+[[tool.hatch.envs.hatch-test.matrix]]
247
+python = ["3.9", "pypy3.9"]
248
+parser-version = ["LL1"]
249
+
250
+[tool.hatch.envs.hatch-test.overrides]
251
+matrix.parser-version.env-vars = [
252
+    { key = "PYTHONOLDPARSER", value = "1", if = ["LL1"] },
253
+]
254
+
255
+[tool.hatch.envs.hatch-test.scripts]
256
+run = "pytest --hypothesis-profile={env:HYPOTHESIS_PROFILE:default}{env:HATCH_TEST_ARGS:} {args}"
257
+run-cov = "coverage run -m pytest --hypothesis-profile={env:HYPOTHESIS_PROFILE:default}{env:HATCH_TEST_ARGS:} {args}"
258
+cov-combine = "coverage combine"
259
+cov-report = "coverage report"
260
+
261
+[tool.hatch.envs.types]
262
+extra-dependencies = [
263
+  "mypy>=1.0.0",
264
+]
265
+[tool.hatch.envs.types.scripts]
266
+check = "mypy --install-types --non-interactive {args:src/fakesshagent tests}"
267
+
268
+[tool.mypy]
269
+files = ['src/**/*.py', 'tests/**/*.py']
270
+mypy_path = '$MYPY_CONFIG_FILE_DIR/src:$MYPY_CONFIG_FILE_DIR/other-stubs'
271
+explicit_package_bases = true
272
+implicit_reexport = false
273
+sqlite_cache = true
274
+enable_error_code = ['ignore-without-code']
275
+
276
+[tool.pyrefly]
277
+project-includes = [
278
+    "src/**/*.py",
279
+    "tests/**/*.py",
280
+]
281
+search-path = [
282
+    "src",
283
+    ".",
284
+]
285
+preset = "legacy"
286
+infer-return-types = "never"
287
+permissive-ignores = true
288
+
289
+[tool.pyrefly.errors]
290
+unnecessary-type-conversion = "ignore"
291
+
292
+[tool.pytest.ini_options]
293
+addopts = '--doctest-modules --dist=loadgroup --import-mode=importlib'
294
+pythonpath = [
295
+    'tests', # needed for re-importing test machinery with multiprocessing (spawn)
296
+]
297
+testpaths = [
298
+    'src', # doctests in API documentation
299
+    'tests', # other tests
300
+]
301
+xfail_strict = true
302
+
303
+[tool.ruff]
304
+line-length = 79
305
+src = ["src"]
306
+
307
+[tool.ruff.format]
308
+docstring-code-format = true
309
+docstring-code-line-length = "dynamic"
310
+preview = true
311
+quote-style = "double"
312
+
313
+[tool.ruff.lint]
314
+ignore = [
315
+    # Suggested ignore by ruff when also using ruff to format.  We *do*
316
+    # check for E501, because this usually only happens when there is
317
+    # a text string that should be manually broken.  However, for
318
+    # automated quality control, that specific check is turned off so
319
+    # that the linting and formatting can proceed, and then *afterwards*
320
+    # the files are re-linted.  We hope that any *true* E501 errors left
321
+    # over from this *are* text strings that should be manually broken.
322
+    'tab-indentation', 'indentation-with-invalid-multiple',
323
+    'indentation-with-invalid-multiple-comment', 'over-indented',
324
+    'docstring-tab-indentation', 'triple-single-quotes',
325
+    'bad-quotes-inline-string', 'bad-quotes-multiline-string',
326
+    'bad-quotes-docstring', 'avoidable-escaped-quote',
327
+    'missing-trailing-comma', 'prohibited-trailing-comma',
328
+    'single-line-implicit-string-concatenation',
329
+    'multi-line-implicit-string-concatenation',
330
+    # We use `assert` regularly to appease the type checker, and because
331
+    # it is the right language tool for this job.
332
+    'assert',
333
+    # The formatter takes care of trailing commas and docstring code
334
+    # automatically.
335
+    'missing-trailing-comma', 'doc-line-too-long',
336
+    # We document transitive exceptions as well (if we feel they would
337
+    # be surprising to the user otherwise).
338
+    'docstring-extraneous-exception',
339
+    # We currently don't have issues for every TODO.  Forcing an issue
340
+    # also goes against the philosophy of TODOs as low-overhead markers
341
+    # for future work; see
342
+    # https://gist.github.com/dmnd/ed5d8ef8de2e4cfea174bd5dafcda382 .
343
+    'missing-todo-link',
344
+    # We somewhat regularly use loops where each iteration needs
345
+    # a separate try-except block.
346
+    'try-except-in-loop',
347
+    # We do not currently use pathlib.  The PTH rules are unselected,
348
+    # but FURB includes several pathlib-related rules.
349
+    'read-whole-file', 'write-whole-file',
350
+    # We catch type-ignore comments without specific code via the mypy
351
+    # configuration, not via ruff.
352
+    'blanket-type-ignore',
353
+    # Provisionally ignore the `__init__.py` rule, until we can adapt our
354
+    # codebase.
355
+    'non-empty-init-module',
356
+    # We prefer noqa comments over ruff-specific ignores, for tool
357
+    # compatibility reasons, even if the comments themselves become harder
358
+    # to read. Using them in the ruff config section is fine thought,
359
+    # because that *is* ruff-specific.
360
+    'noqa-comments',
361
+    # Still undecided about this one.
362
+    'too-many-statements-in-try-clause',
363
+]
364
+preview = true
365
+# We select here in the order of presentation on the ruff documentation
366
+# website.  ruff default selection (v0.6.2) is merely E4, E7, E9 and F.
367
+select = [
368
+    'F', 'E', 'W', 'C90', 'I', 'N', 'D', 'UP', 'YTT',
369
+    'ANN', 'ASYNC', 'S', 'BLE', 'FBT', 'B', 'A', 'COM',
370
+    'CPY', 'C4', 'DTZ', 'T10', 'DJ', 'EM', 'EXE', 'FA',
371
+    'ISC', 'ICN', 'LOG', 'G', 'INP', 'PIE', 'T20', 'PYI',
372
+    'PT', 'Q', 'RET', 'SLF', 'SLOT', 'SIM', 'TID', 'TC',
373
+    'INT', 'ARG', 'PTH', 'TD',
374
+    # We use TODOs and FIXMEs as notes for later, and don't want the
375
+    # linter to nag about every occurrence.  Disable 'FIX'.
376
+    #
377
+    # The "eradicate" rule is prone to a lot of false positives, and it
378
+    # is unclear to me, and probably confusing to read, where to apply
379
+    # a noqa marker.  Instead, disable 'ERA', and if necessary, specify
380
+    # it on the command-line.
381
+    'PD', 'PGH', 'PL', 'TRY', 'FLY', 'NPY', 'FAST',
382
+    'AIR', 'PERF', 'FURB', 'DOC', 'RUF',
383
+]
384
+
385
+[tool.ruff.lint.per-file-ignores]
386
+"**/scripts/*" = [
387
+    # Suggested by hatch.
388
+    'INP',
389
+    # Suggested by hatch.
390
+    'T20',
391
+]
392
+"**/tests/**/*" = [
393
+    # Suggested by hatch, assumingly because it may be important to
394
+    # verify that the value is exactly the empty string, and not just
395
+    # any falsy value.
396
+    'compare-to-empty-string',
397
+    # Suggested by hatch, assumingly because tests may use "magic
398
+    # values".
399
+    'magic-value-comparison',
400
+    # Suggested by hatch, because tests are typically organized as
401
+    # classes and instance methods but may not really be using the
402
+    # `self` argument.
403
+    'no-self-use',
404
+    # Suggested by hatch, because these warnings may be precisely what
405
+    # the tests are supposed to test.
406
+    'S',
407
+    # Suggested by hatch, because pytest-style tests conventionally
408
+    # import code from each other via relative imports.
409
+    'relative-imports',
410
+    # Our tests regularly use arguments named `input` to store an input
411
+    # (text-/byte-)string.
412
+    'builtin-argument-shadowing',
413
+    # We regularly annotate pytest fixtures like monkeypatch as `Any`.
414
+    'any-type',
415
+    # Our tests generally don't contain docstrings.
416
+    'D', 'DOC',
417
+    # Our tests are regularly parametrized with booleans, for benign
418
+    # purposes.
419
+    'FBT',
420
+    # One of our standard modules is called `derivepassphrase._types`.
421
+    # Importing this from the tests directory would then automatically
422
+    # trigger `PLC2701`.
423
+    'import-private-name',
424
+    # Too many public methods/arguments/returns/branches/locals doesn't
425
+    # really apply here.
426
+    'too-many-public-methods', 'too-many-return-statements',
427
+    'too-many-branches', 'too-many-arguments', 'too-many-locals',
428
+    'too-many-statements', 'too-many-boolean-expressions',
429
+    'too-many-positional-arguments',
430
+    # To fully test the `derivepassphrase.cli` module (and a couple
431
+    # other things), we need to call and to mock several internal
432
+    # functions, which would automatically trigger `SLF001`.
433
+    'private-member-access',
434
+    # pytest does not support sensible introspection of
435
+    # `assert all(...)` expressions in tests the same way it supports
436
+    # introspection in `asssert all([...])`.  So the extra list
437
+    # comprehension actually improves debuggability in this case.
438
+    'unnecessary-comprehension-in-call',
439
+    # The tests sometimes include long strings (in non-Python formats)
440
+    # that should be included verbatim, without artificial line
441
+    # breaking, so they can be grepped for.
442
+    'line-too-long',
443
+]
444
+
445
+[tool.ruff.lint.flake8-copyright]
446
+# Include hatch-enforced SPDX-FileCopyrightText in check.
447
+notice-rgx = '(?i)(?:Copyright\s+((?:\(C\)|©)\s+)?|SPDX-FileCopyrightText:\s+)\d{4}((-|,\s)\d{4})*'
448
+
449
+[tool.ruff.lint.flake8-pytest-style]
450
+fixture-parentheses = false
451
+mark-parentheses = false
452
+parametrize-names-type = 'list'
453
+
454
+[tool.ruff.lint.flake8-tidy-imports]
455
+ban-relative-imports = "all"
456
+
457
+[tool.ruff.lint.isort]
458
+known-first-party = [
459
+    "derivepassphrase_sshagentsocketprovider",
460
+    "fakesshagent",
461
+]
462
+
463
+[tool.ruff.lint.pycodestyle]
464
+ignore-overlong-task-comments = true  # for E501
465
+max-doc-length = 72  # for W505
466
+
467
+[tool.ruff.lint.pydocstyle]
468
+convention = 'google'
469
+
470
+[tool.scriv]
471
+version = "literal: pyproject.toml: project.version"
472
+format = "md"
473
+fragment_directory = "docs/changelog.d"
474
+output_file = "docs/changelog.md"
475
+insert_marker = "<!-- scriv changelog start -->"
476
+end_marker = "<!-- scriv changelog end -->"
477
+md_header_level = "2"
478
+entry_title_template = "{% if version %}{{ version }} ({% endif %}{{ date.strftime('%Y-%m-%d') }}{% if version %}){% endif %}"
... ...
@@ -0,0 +1,13 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""A non-hardened implementation of the SSH agent protocol."""
6
+
7
+from fakesshagent.machinery import (
8
+    StubbedSSHAgentSocket as StubbedSSHAgentSocket,
9
+)
10
+
11
+__version__ = "0.1"
12
+
13
+__all__ = ("StubbedSSHAgentSocket",)
... ...
@@ -0,0 +1,87 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Types for the fake SSH agent."""
6
+
7
+from __future__ import annotations
8
+
9
+import enum
10
+
11
+
12
+class SSH_AGENTC(int, enum.Enum):  # noqa: N801
13
+    """SSH agent protocol numbers: client requests.
14
+
15
+    Attributes:
16
+        REQUEST_IDENTITIES (int):
17
+            List identities.  Expecting
18
+            [`SSH_AGENT.IDENTITIES_ANSWER`][].
19
+        SIGN_REQUEST (int):
20
+            Sign data.  Expecting [`SSH_AGENT.SIGN_RESPONSE`][].
21
+        ADD_IDENTITY (int):
22
+            Add an (SSH2) identity.
23
+        REMOVE_IDENTITY (int):
24
+            Remove an (SSH2) identity.
25
+        ADD_ID_CONSTRAINED (int):
26
+            Add an (SSH2) identity, including key constraints.
27
+        EXTENSION (int):
28
+            Issue a named request that isn't part of the core agent
29
+            protocol.  Expecting [`SSH_AGENT.EXTENSION_RESPONSE`][] or
30
+            [`SSH_AGENT.EXTENSION_FAILURE`][] if the named request is
31
+            supported, [`SSH_AGENT.FAILURE`][] otherwise.
32
+
33
+    """
34
+
35
+    REQUEST_IDENTITIES = 11
36
+    """"""
37
+    SIGN_REQUEST = 13
38
+    """"""
39
+    ADD_IDENTITY = 17
40
+    """"""
41
+    REMOVE_IDENTITY = 18
42
+    """"""
43
+    ADD_ID_CONSTRAINED = 25
44
+    """"""
45
+    EXTENSION = 27
46
+    """"""
47
+
48
+    def __bytes__(self) -> bytes:  # pragma: no cover [unused]
49
+        """Return the corresponding request code."""
50
+        return int.to_bytes(self, 1, "big", signed=False)
51
+
52
+
53
+class SSH_AGENT(int, enum.Enum):  # noqa: N801
54
+    """SSH agent protocol numbers: server replies.
55
+
56
+    Attributes:
57
+        FAILURE (int):
58
+            Generic failure code.
59
+        SUCCESS (int):
60
+            Generic success code.
61
+        IDENTITIES_ANSWER (int):
62
+            Successful answer to [`SSH_AGENTC.REQUEST_IDENTITIES`][].
63
+        SIGN_RESPONSE (int):
64
+            Successful answer to [`SSH_AGENTC.SIGN_REQUEST`][].
65
+        EXTENSION_FAILURE (int):
66
+            Unsuccessful answer to [`SSH_AGENTC.EXTENSION`][].
67
+        EXTENSION_RESPONSE (int):
68
+            Successful answer to [`SSH_AGENTC.EXTENSION`][].
69
+
70
+    """
71
+
72
+    FAILURE = 5
73
+    """"""
74
+    SUCCESS = 6
75
+    """"""
76
+    IDENTITIES_ANSWER = 12
77
+    """"""
78
+    SIGN_RESPONSE = 14
79
+    """"""
80
+    EXTENSION_FAILURE = 28
81
+    """"""
82
+    EXTENSION_RESPONSE = 29
83
+    """"""
84
+
85
+    def __bytes__(self) -> bytes:  # pragma: no cover [unused]
86
+        """Return the corresponding response code."""
87
+        return int.to_bytes(self, 1, "big", signed=False)
... ...
@@ -0,0 +1,837 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Data for the fake SSH agent."""
6
+
7
+# ruff: noqa: E501
8
+
9
+from __future__ import annotations
10
+
11
+import enum
12
+from typing import TYPE_CHECKING
13
+
14
+from typing_extensions import NamedTuple
15
+
16
+if TYPE_CHECKING:
17
+    from collections.abc import Mapping
18
+
19
+    from typing_extensions import Any
20
+
21
+
22
+class SSHTestKeyDeterministicSignatureClass(str, enum.Enum):
23
+    """The class of a deterministic signature from an SSH test key.
24
+
25
+    Attributes:
26
+        SPEC:
27
+            A deterministic signature directly implied by the
28
+            specification of the signature algorithm.
29
+        RFC_6979:
30
+            A deterministic signature as specified by RFC 6979.  Only
31
+            used with DSA and ECDSA keys (that aren't also EdDSA keys).
32
+        Pageant_068_080:
33
+            A deterministic signature as specified by Pageant 0.68.
34
+            Only used with DSA and ECDSA keys (that aren't also EdDSA
35
+            keys), and only used with Pageant from 0.68 up to and
36
+            including 0.80.
37
+
38
+            Usage of this signature class together with an ECDSA NIST
39
+            P-521 key [turned out to leak enough information per
40
+            signature to quickly compromise the entire private key
41
+            (CVE-2024-31497)][PUTTY_CVE_2024_31497], so newer Pageant
42
+            versions abandon this signature class in favor of RFC 6979.
43
+
44
+            [PUTTY_CVE_2024_31497]: https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/vuln-p521-bias.html
45
+
46
+    """
47
+
48
+    SPEC = enum.auto()
49
+    """"""
50
+    RFC_6979 = enum.auto()
51
+    """"""
52
+    Pageant_068_080 = enum.auto()
53
+    """"""
54
+
55
+
56
+class SSHTestKeyDeterministicSignature(NamedTuple):
57
+    """An SSH test key deterministic signature.
58
+
59
+    Attributes:
60
+        signature:
61
+            The binary signature of the [vault UUID][vault.Vault.UUID]
62
+            under this signature class.
63
+        derived_passphrase:
64
+            The equivalent master passphrase derived from this
65
+            signature.
66
+        signature_class:
67
+            The [signature
68
+            class][SSHTestKeyDeterministicSignatureClass].
69
+
70
+    """
71
+
72
+    signature: bytes
73
+    """"""
74
+    derived_passphrase: bytes
75
+    """"""
76
+    signature_class: SSHTestKeyDeterministicSignatureClass = (
77
+        SSHTestKeyDeterministicSignatureClass.SPEC
78
+    )
79
+    """"""
80
+
81
+
82
+class SSHTestKey(NamedTuple):
83
+    """An SSH test key.
84
+
85
+    Attributes:
86
+        public_key:
87
+            The SSH public key string, as used e.g. by OpenSSH's
88
+            `authorized_keys` file.  Includes a comment.
89
+        public_key_data:
90
+            The SSH protocol wire format of the public key.
91
+        private_key:
92
+            A base64 encoded representation of the private key, in
93
+            OpenSSH's v1 private key format.
94
+        private_key_blob:
95
+            The SSH protocol wire format of the private key.
96
+        expected_signatures:
97
+            A mapping of deterministic signature classes to the
98
+            expected, deterministic signature (of that class) of the
99
+            vault UUID for this key, together with the respective
100
+            "equivalent master passphrase" derived from this signature.
101
+
102
+    """
103
+
104
+    public_key: bytes
105
+    """"""
106
+    public_key_data: bytes
107
+    """"""
108
+    private_key: bytes
109
+    """"""
110
+    private_key_blob: bytes
111
+    """"""
112
+    expected_signatures: Mapping[
113
+        SSHTestKeyDeterministicSignatureClass, SSHTestKeyDeterministicSignature
114
+    ]
115
+    """"""
116
+
117
+    def is_suitable(
118
+        self,
119
+        *,
120
+        client: Any = None,  # noqa: ANN401
121
+    ) -> bool:
122
+        """Return if this key is suitable for use with vault.
123
+
124
+        Args:
125
+            client:
126
+                An optional SSH agent client to check for additional
127
+                deterministic key types. If not given, assume no such
128
+                types.
129
+
130
+        """
131
+        raise NotImplementedError
132
+
133
+
134
+ALL_KEYS: Mapping[str, SSHTestKey] = {
135
+    "ed25519": SSHTestKey(
136
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
137
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
138
+QyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdgAAAKDweO7H8Hju
139
+xwAAAAtzc2gtZWQyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdg
140
+AAAEAbM/A869nkWZbe2tp3Dm/L6gitvmpH/aRZt8sBII3ExYF4gWgm1gJIXw//Mkhv5MEw
141
+idwcakUGCekJD/vCEml2AAAAG3Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQEC
142
+-----END OPENSSH PRIVATE KEY-----
143
+""",
144
+        private_key_blob=bytes.fromhex("""
145
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
146
+            00 00 00 20
147
+            81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
148
+            30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
149
+            00 00 00 40
150
+            1b 33 f0 3c eb d9 e4 59 96 de da da 77 0e 6f cb
151
+            ea 08 ad be 6a 47 fd a4 59 b7 cb 01 20 8d c4 c5
152
+            81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
153
+            30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
154
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69 74
155
+            68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
156
+"""),
157
+        public_key=rb"""ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2 test key without passphrase
158
+""",
159
+        public_key_data=bytes.fromhex("""
160
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
161
+            00 00 00 20
162
+            81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
163
+            30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
164
+"""),
165
+        expected_signatures={
166
+            SSHTestKeyDeterministicSignatureClass.SPEC: SSHTestKeyDeterministicSignature(
167
+                signature=bytes.fromhex("""
168
+                    00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
169
+                    00 00 00 40
170
+                    f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
171
+                    66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
172
+                    0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
173
+                    1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
174
+"""),
175
+                derived_passphrase=rb"""8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg==""",
176
+            ),
177
+        },
178
+    ),
179
+    # Currently only supported by PuTTY (which is deficient in other
180
+    # niceties of the SSH agent and the agent's client).
181
+    "ed448": SSHTestKey(
182
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
183
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAASgAAAAlz
184
+c2gtZWQ0NDgAAAA54vZy009Wu8wExjvEb3hqtLz1GO/+d5vmGUbErWQ4AUO9mYLT
185
+zHJHc2m4s+yWzP29Cc3EcxizLG8AAAAA8BdhfCcXYXwnAAAACXNzaC1lZDQ0OAAA
186
+ADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM
187
+/b0JzcRzGLMsbwAAAAByM7GIMRvWJB3YD6SIpAF2uudX4ozZe0X917wPwiBrs373
188
+9TM1n94Nib6hrxGNmCk2iBQDe2KALPgA4vZy009Wu8wExjvEb3hqtLz1GO/+d5vm
189
+GUbErWQ4AUO9mYLTzHJHc2m4s+yWzP29Cc3EcxizLG8AAAAAG3Rlc3Qga2V5IHdp
190
+dGhvdXQgcGFzc3BocmFzZQECAwQFBgcICQ==
191
+-----END OPENSSH PRIVATE KEY-----
192
+""",
193
+        private_key_blob=bytes.fromhex("""
194
+            00 00 00 09 73 73 68 2d 65 64 34 34 38
195
+            00 00 00 39 e2 f6 72 d3 4f 56 bb cc 04
196
+            c6 3b c4 6f 78 6a b4 bc f5 18 ef fe 77 9b e6 19
197
+            46 c4 ad 64 38 01 43 bd 99 82 d3 cc 72 47 73 69
198
+            b8 b3 ec 96 cc fd bd 09 cd c4 73 18 b3 2c 6f 00
199
+            00 00 00 72 33 b1
200
+            88 31 1b d6 24 1d d8 0f a4 88 a4 01 76 ba e7 57
201
+            e2 8c d9 7b 45 fd d7 bc 0f c2 20 6b b3 7e f7 f5
202
+            33 35 9f de 0d 89 be a1 af 11 8d 98 29 36 88 14
203
+            03 7b 62 80 2c f8 00 e2 f6 72 d3 4f 56 bb cc 04
204
+            c6 3b c4 6f 78 6a b4 bc f5 18 ef fe 77 9b e6 19
205
+            46 c4 ad 64 38 01 43 bd 99 82 d3 cc 72 47 73 69
206
+            b8 b3 ec 96 cc fd bd 09 cd c4 73 18 b3 2c 6f 00
207
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
208
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
209
+"""),
210
+        public_key=rb"""ssh-ed448 AAAACXNzaC1lZDQ0OAAAADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM/b0JzcRzGLMsbwA= test key without passphrase
211
+""",
212
+        public_key_data=bytes.fromhex("""
213
+            00 00 00 09 73 73 68 2d 65 64 34 34 38
214
+            00 00 00 39 e2 f6 72 d3 4f 56 bb cc 04
215
+            c6 3b c4 6f 78 6a b4 bc f5 18 ef fe 77 9b e6 19
216
+            46 c4 ad 64 38 01 43 bd 99 82 d3 cc 72 47 73 69
217
+            b8 b3 ec 96 cc fd bd 09 cd c4 73 18 b3 2c 6f 00
218
+        """),
219
+        expected_signatures={
220
+            SSHTestKeyDeterministicSignatureClass.SPEC: SSHTestKeyDeterministicSignature(
221
+                signature=bytes.fromhex("""
222
+                    00 00 00 09 73 73 68 2d 65 64 34 34 38
223
+                    00 00 00 72 06 86
224
+                    f4 64 a4 a6 ba d9 c3 22 c4 93 49 99 fc 11 de 67
225
+                    97 08 f2 d8 b7 3c 2c 13 e7 c5 1c 1e 92 a6 0e d8
226
+                    2f 6d 81 03 82 00 e3 72 e4 32 6d 72 d2 6d 32 84
227
+                    3f cc a9 1e 57 2c 00 9a b3 99 de 45 da ce 2e d1
228
+                    db e5 89 f3 35 be 24 58 90 c6 ca 04 f0 db 88 80
229
+                    db bd 77 7c 80 20 7f 3a 48 61 f6 1f ae a9 5e 53
230
+                    7b e0 9d 93 1e ea dc eb b5 cd 56 4c ea 8f 08 00
231
+"""),
232
+                derived_passphrase=rb"""Bob0ZKSmutnDIsSTSZn8Ed5nlwjy2Lc8LBPnxRwekqYO2C9tgQOCAONy5DJtctJtMoQ/zKkeVywAmrOZ3kXazi7R2+WJ8zW+JFiQxsoE8NuIgNu9d3yAIH86SGH2H66pXlN74J2THurc67XNVkzqjwgA""",
233
+            ),
234
+        },
235
+    ),
236
+    "rsa": SSHTestKey(
237
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
238
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
239
+NhAAAAAwEAAQAAAYEAsaHu6Xs4cVsuDSNJlMCqoPVgmDgEviI8TfXmHKqX3JkIqI3LsvV7
240
+Ijf8WCdTveEq7CkuZhImtsR52AOEVAoU8mDXDNr+nJ5wUPzf1UIaRjDe0lcXW4SlF01hQs
241
+G4wYDuqxshwelraB/L3e0zhD7fjYHF8IbFsqGlFHWEwOtlfhhfbxJsTGguLm4A8/gdEJD5
242
+2rkqDcZpIXCHtJbCzW9aQpWcs/PDw5ylwl/3dB7jfxyfrGz4O3QrzsqhWEsip97mOmwl6q
243
+CHbq8V8x9zu89D/H+bG5ijqxhijbjcVUW3lZfw/97gy9J6rG31HNar5H8GycLTFwuCFepD
244
+mTEpNgQLKoe8ePIEPq4WHhFUovBdwlrOByUKKqxreyvWt5gkpTARz+9Lt8OjBO3rpqK8sZ
245
+VKH3sE3de2RJM3V9PJdmZSs2b8EFK3PsUGdlMPM9pn1uk4uIItKWBmooOynuD8Ll6aPwuW
246
+AFn3l8nLLyWdrmmEYzHWXiRjQJxy1Bi5AbHMOWiPAAAFkDPkuBkz5LgZAAAAB3NzaC1yc2
247
+EAAAGBALGh7ul7OHFbLg0jSZTAqqD1YJg4BL4iPE315hyql9yZCKiNy7L1eyI3/FgnU73h
248
+KuwpLmYSJrbEedgDhFQKFPJg1wza/pyecFD839VCGkYw3tJXF1uEpRdNYULBuMGA7qsbIc
249
+Hpa2gfy93tM4Q+342BxfCGxbKhpRR1hMDrZX4YX28SbExoLi5uAPP4HRCQ+dq5Kg3GaSFw
250
+h7SWws1vWkKVnLPzw8OcpcJf93Qe438cn6xs+Dt0K87KoVhLIqfe5jpsJeqgh26vFfMfc7
251
+vPQ/x/mxuYo6sYYo243FVFt5WX8P/e4MvSeqxt9RzWq+R/BsnC0xcLghXqQ5kxKTYECyqH
252
+vHjyBD6uFh4RVKLwXcJazgclCiqsa3sr1reYJKUwEc/vS7fDowTt66aivLGVSh97BN3Xtk
253
+STN1fTyXZmUrNm/BBStz7FBnZTDzPaZ9bpOLiCLSlgZqKDsp7g/C5emj8LlgBZ95fJyy8l
254
+na5phGMx1l4kY0CcctQYuQGxzDlojwAAAAMBAAEAAAF/cNVYT+Om4x9+SItcz5bOByGIOj
255
+yWUH8f9rRjnr5ILuwabIDgvFaVG+xM1O1hWADqzMnSEcknHRkTYEsqYPykAtxFvjOFEh70
256
+6qRUJ+fVZkqRGEaI3oWyWKTOhcCIYImtONvb0LOv/HQ2H2AXCoeqjST1qr/xSuljBtcB8u
257
+wxs3EqaO1yU7QoZpDcMX9plH7Rmc9nNfZcgrnktPk2deX2+Y/A5tzdVgG1IeqYp6CBMLNM
258
+uhL0OPdDehgBoDujx+rhkZ1gpo1wcULIM94NL7VSHBPX0Lgh9T+3j1HVP+YnMAvhfOvfct
259
+LlbJ06+TYGRAMuF2LPCAZM/m0FEyAurRgWxAjLXm+4kp2GAJXlw82deDkQ+P8cHNT6s9ZH
260
+R5YSy3lpZ35594ZMOLR8KqVvhgJGF6i9019BiF91SDxjE+sp6dNGfN8W+64tHdDv2a0Mso
261
++8Qjyx7sTpi++EjLU8Iy73/e4B8qbXMyheyA/UUfgMtNKShh6sLlrD9h2Sm9RFTuEAAADA
262
+Jh3u7WfnjhhKZYbAW4TsPNXDMrB0/t7xyAQgFmko7JfESyrJSLg1cO+QMOiDgD7zuQ9RSp
263
+NIKdPsnIna5peh979mVjb2HgnikjyJECmBpLdwZKhX7MnIvgKw5lnQXHboEtWCa1N58l7f
264
+srzwbi9pFUuUp9dShXNffmlUCjDRsVLbK5C6+iaIQyCWFYK8mc6dpNkIoPKf+Xg+EJCIFQ
265
+oITqeu30Gc1+M+fdZc2ghq0b6XLthh/uHEry8b68M5KglMAAAAwQDw1i+IdcvPV/3u/q9O
266
+/kzLpKO3tbT89sc1zhjZsDNjDAGluNr6n38iq/XYRZu7UTL9BG+EgFVfIUV7XsYT5e+BPf
267
+13VS94rzZ7maCsOlULX+VdMO2zBucHIoec9RUlRZrfB21B2W7YGMhbpoa5lN3lKJQ7afHo
268
+dXZUMp0cTFbOmbzJgSzO2/NE7BhVwmvcUzTDJGMMKuxBO6w99YKDKRKm0PNLFDz26rWm9L
269
+dNS2MVfVuPMTpzT26HQG4pFageq9cAAADBALzRBXdZF8kbSBa5MTUBVTTzgKQm1C772gJ8
270
+T01DJEXZsVtOv7mUC1/m/by6Hk4tPyvDBuGj9hHq4N7dPqGutHb1q5n0ADuoQjRW7BXw5Q
271
+vC2EAD91xexdorIA5BgXU+qltBqzzBVzVtF7+jOZOjfzOlaTX9I5I5veyeTaTxZj1XXUzi
272
+btBNdMEJJp7ifucYmoYAAwE7K+VlWagDEK2y8Mte9y9E+N0uO2j+h85sQt/UIb2iE/vhcg
273
+Bgp6142WnSCQAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UB
274
+-----END OPENSSH PRIVATE KEY-----
275
+""",
276
+        private_key_blob=bytes.fromhex("""
277
+            00 00 00 07 73 73 68 2d 72 73 61
278
+            00 00 01 81 00
279
+            b1 a1 ee e9 7b 38 71 5b 2e 0d 23 49 94 c0 aa a0
280
+            f5 60 98 38 04 be 22 3c 4d f5 e6 1c aa 97 dc 99
281
+            08 a8 8d cb b2 f5 7b 22 37 fc 58 27 53 bd e1 2a
282
+            ec 29 2e 66 12 26 b6 c4 79 d8 03 84 54 0a 14 f2
283
+            60 d7 0c da fe 9c 9e 70 50 fc df d5 42 1a 46 30
284
+            de d2 57 17 5b 84 a5 17 4d 61 42 c1 b8 c1 80 ee
285
+            ab 1b 21 c1 e9 6b 68 1f cb dd ed 33 84 3e df 8d
286
+            81 c5 f0 86 c5 b2 a1 a5 14 75 84 c0 eb 65 7e 18
287
+            5f 6f 12 6c 4c 68 2e 2e 6e 00 f3 f8 1d 10 90 f9
288
+            da b9 2a 0d c6 69 21 70 87 b4 96 c2 cd 6f 5a 42
289
+            95 9c b3 f3 c3 c3 9c a5 c2 5f f7 74 1e e3 7f 1c
290
+            9f ac 6c f8 3b 74 2b ce ca a1 58 4b 22 a7 de e6
291
+            3a 6c 25 ea a0 87 6e af 15 f3 1f 73 bb cf 43 fc
292
+            7f 9b 1b 98 a3 ab 18 62 8d b8 dc 55 45 b7 95 97
293
+            f0 ff de e0 cb d2 7a ac 6d f5 1c d6 ab e4 7f 06
294
+            c9 c2 d3 17 0b 82 15 ea 43 99 31 29 36 04 0b 2a
295
+            87 bc 78 f2 04 3e ae 16 1e 11 54 a2 f0 5d c2 5a
296
+            ce 07 25 0a 2a ac 6b 7b 2b d6 b7 98 24 a5 30 11
297
+            cf ef 4b b7 c3 a3 04 ed eb a6 a2 bc b1 95 4a 1f
298
+            7b 04 dd d7 b6 44 93 37 57 d3 c9 76 66 52 b3 66
299
+            fc 10 52 b7 3e c5 06 76 53 0f 33 da 67 d6 e9 38
300
+            b8 82 2d 29 60 66 a2 83 b2 9e e0 fc 2e 5e 9a 3f
301
+            0b 96 00 59 f7 97 c9 cb 2f 25 9d ae 69 84 63 31
302
+            d6 5e 24 63 40 9c 72 d4 18 b9 01 b1 cc 39 68 8f
303
+            00 00 00 03 01 00 01
304
+            00 00 01 7f
305
+            70 d5 58 4f e3 a6 e3 1f 7e 48 8b 5c cf 96 ce
306
+            07 21 88 3a 3c 96 50 7f 1f f6 b4 63 9e be 48 2e
307
+            ec 1a 6c 80 e0 bc 56 95 1b ec 4c d4 ed 61 58 00
308
+            ea cc c9 d2 11 c9 27 1d 19 13 60 4b 2a 60 fc a4
309
+            02 dc 45 be 33 85 12 1e f4 ea a4 54 27 e7 d5 66
310
+            4a 91 18 46 88 de 85 b2 58 a4 ce 85 c0 88 60 89
311
+            ad 38 db db d0 b3 af fc 74 36 1f 60 17 0a 87 aa
312
+            8d 24 f5 aa bf f1 4a e9 63 06 d7 01 f2 ec 31 b3
313
+            71 2a 68 ed 72 53 b4 28 66 90 dc 31 7f 69 94 7e
314
+            d1 99 cf 67 35 f6 5c 82 b9 e4 b4 f9 36 75 e5 f6
315
+            f9 8f c0 e6 dc dd 56 01 b5 21 ea 98 a7 a0 81 30
316
+            b3 4c ba 12 f4 38 f7 43 7a 18 01 a0 3b a3 c7 ea
317
+            e1 91 9d 60 a6 8d 70 71 42 c8 33 de 0d 2f b5 52
318
+            1c 13 d7 d0 b8 21 f5 3f b7 8f 51 d5 3f e6 27 30
319
+            0b e1 7c eb df 72 d2 e5 6c 9d 3a f9 36 06 44 03
320
+            2e 17 62 cf 08 06 4c fe 6d 05 13 20 2e ad 18 16
321
+            c4 08 cb 5e 6f b8 92 9d 86 00 95 e5 c3 cd 9d 78
322
+            39 10 f8 ff 1c 1c d4 fa b3 d6 47 47 96 12 cb 79
323
+            69 67 7e 79 f7 86 4c 38 b4 7c 2a a5 6f 86 02 46
324
+            17 a8 bd d3 5f 41 88 5f 75 48 3c 63 13 eb 29 e9
325
+            d3 46 7c df 16 fb ae 2d 1d d0 ef d9 ad 0c b2 8f
326
+            bc 42 3c b1 ee c4 e9 8b ef 84 8c b5 3c 23 2e f7
327
+            fd ee 01 f2 a6 d7 33 28 5e c8 0f d4 51 f8 0c b4
328
+            d2 92 86 1e ac 2e 5a c3 f6 1d 92 9b d4 45 4e e1
329
+            00 00 00 c0
330
+            26 1d ee ed 67 e7 8e 18 4a 65 86 c0 5b 84 ec 3c
331
+            d5 c3 32 b0 74 fe de f1 c8 04 20 16 69 28 ec 97
332
+            c4 4b 2a c9 48 b8 35 70 ef 90 30 e8 83 80 3e f3
333
+            b9 0f 51 4a 93 48 29 d3 ec 9c 89 da e6 97 a1 f7
334
+            bf 66 56 36 f6 1e 09 e2 92 3c 89 10 29 81 a4 b7
335
+            70 64 a8 57 ec c9 c8 be 02 b0 e6 59 d0 5c 76 e8
336
+            12 d5 82 6b 53 79 f2 5e df b2 bc f0 6e 2f 69 15
337
+            4b 94 a7 d7 52 85 73 5f 7e 69 54 0a 30 d1 b1 52
338
+            db 2b 90 ba fa 26 88 43 20 96 15 82 bc 99 ce 9d
339
+            a4 d9 08 a0 f2 9f f9 78 3e 10 90 88 15 0a 08 4e
340
+            a7 ae df 41 9c d7 e3 3e 7d d6 5c da 08 6a d1 be
341
+            97 2e d8 61 fe e1 c4 af 2f 1b eb c3 39 2a 09 4c
342
+            00 00 00 c1 00
343
+            f0 d6 2f 88 75 cb cf 57 fd ee fe af 4e fe 4c cb
344
+            a4 a3 b7 b5 b4 fc f6 c7 35 ce 18 d9 b0 33 63 0c
345
+            01 a5 b8 da fa 9f 7f 22 ab f5 d8 45 9b bb 51 32
346
+            fd 04 6f 84 80 55 5f 21 45 7b 5e c6 13 e5 ef 81
347
+            3d fd 77 55 2f 78 af 36 7b 99 a0 ac 3a 55 0b 5f
348
+            e5 5d 30 ed b3 06 e7 07 22 87 9c f5 15 25 45 9a
349
+            df 07 6d 41 d9 6e d8 18 c8 5b a6 86 b9 94 dd e5
350
+            28 94 3b 69 f1 e8 75 76 54 32 9d 1c 4c 56 ce 99
351
+            bc c9 81 2c ce db f3 44 ec 18 55 c2 6b dc 53 34
352
+            c3 24 63 0c 2a ec 41 3b ac 3d f5 82 83 29 12 a6
353
+            d0 f3 4b 14 3c f6 ea b5 a6 f4 b7 4d 4b 63 15 7d
354
+            5b 8f 31 3a 73 4f 6e 87 40 6e 29 15 a8 1e ab d7
355
+            00 00 00 c1 00
356
+            bc d1 05 77 59 17 c9 1b 48 16 b9 31 35 01 55 34
357
+            f3 80 a4 26 d4 2e fb da 02 7c 4f 4d 43 24 45 d9
358
+            b1 5b 4e bf b9 94 0b 5f e6 fd bc ba 1e 4e 2d 3f
359
+            2b c3 06 e1 a3 f6 11 ea e0 de dd 3e a1 ae b4 76
360
+            f5 ab 99 f4 00 3b a8 42 34 56 ec 15 f0 e5 0b c2
361
+            d8 40 03 f7 5c 5e c5 da 2b 20 0e 41 81 75 3e aa
362
+            5b 41 ab 3c c1 57 35 6d 17 bf a3 39 93 a3 7f 33
363
+            a5 69 35 fd 23 92 39 bd ec 9e 4d a4 f1 66 3d 57
364
+            5d 4c e2 6e d0 4d 74 c1 09 26 9e e2 7e e7 18 9a
365
+            86 00 03 01 3b 2b e5 65 59 a8 03 10 ad b2 f0 cb
366
+            5e f7 2f 44 f8 dd 2e 3b 68 fe 87 ce 6c 42 df d4
367
+            21 bd a2 13 fb e1 72 00 60 a7 ad 78 d9 69 d2 09
368
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
369
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
370
+"""),
371
+        public_key=rb"""ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8= test key without passphrase
372
+""",
373
+        public_key_data=bytes.fromhex("""
374
+            00 00 00 07 73 73 68 2d 72 73 61
375
+            00 00 00 03 01 00 01
376
+            00 00 01 81 00
377
+            b1 a1 ee e9 7b 38 71 5b 2e 0d 23 49 94 c0 aa a0
378
+            f5 60 98 38 04 be 22 3c 4d f5 e6 1c aa 97 dc 99
379
+            08 a8 8d cb b2 f5 7b 22 37 fc 58 27 53 bd e1 2a
380
+            ec 29 2e 66 12 26 b6 c4 79 d8 03 84 54 0a 14 f2
381
+            60 d7 0c da fe 9c 9e 70 50 fc df d5 42 1a 46 30
382
+            de d2 57 17 5b 84 a5 17 4d 61 42 c1 b8 c1 80 ee
383
+            ab 1b 21 c1 e9 6b 68 1f cb dd ed 33 84 3e df 8d
384
+            81 c5 f0 86 c5 b2 a1 a5 14 75 84 c0 eb 65 7e 18
385
+            5f 6f 12 6c 4c 68 2e 2e 6e 00 f3 f8 1d 10 90 f9
386
+            da b9 2a 0d c6 69 21 70 87 b4 96 c2 cd 6f 5a 42
387
+            95 9c b3 f3 c3 c3 9c a5 c2 5f f7 74 1e e3 7f 1c
388
+            9f ac 6c f8 3b 74 2b ce ca a1 58 4b 22 a7 de e6
389
+            3a 6c 25 ea a0 87 6e af 15 f3 1f 73 bb cf 43 fc
390
+            7f 9b 1b 98 a3 ab 18 62 8d b8 dc 55 45 b7 95 97
391
+            f0 ff de e0 cb d2 7a ac 6d f5 1c d6 ab e4 7f 06
392
+            c9 c2 d3 17 0b 82 15 ea 43 99 31 29 36 04 0b 2a
393
+            87 bc 78 f2 04 3e ae 16 1e 11 54 a2 f0 5d c2 5a
394
+            ce 07 25 0a 2a ac 6b 7b 2b d6 b7 98 24 a5 30 11
395
+            cf ef 4b b7 c3 a3 04 ed eb a6 a2 bc b1 95 4a 1f
396
+            7b 04 dd d7 b6 44 93 37 57 d3 c9 76 66 52 b3 66
397
+            fc 10 52 b7 3e c5 06 76 53 0f 33 da 67 d6 e9 38
398
+            b8 82 2d 29 60 66 a2 83 b2 9e e0 fc 2e 5e 9a 3f
399
+            0b 96 00 59 f7 97 c9 cb 2f 25 9d ae 69 84 63 31
400
+            d6 5e 24 63 40 9c 72 d4 18 b9 01 b1 cc 39 68 8f
401
+"""),
402
+        expected_signatures={
403
+            SSHTestKeyDeterministicSignatureClass.SPEC: SSHTestKeyDeterministicSignature(
404
+                signature=bytes.fromhex("""
405
+                    00 00 00 07 73 73 68 2d 72 73 61
406
+                    00 00 01 80
407
+                    a2 10 7c 2e f6 bb 53 a8 74 2a a1 19 99 ad 81 be
408
+                    79 9c ed d6 9d 09 4e 6e c5 18 48 33 90 77 99 68
409
+                    f7 9e 03 5a cd 4e 18 eb 89 7d 85 a2 ee ae 4a 92
410
+                    f6 6f ce b9 fe 86 7f 2a 6b 31 da 6e 1a fe a2 a5
411
+                    88 b8 44 7f a1 76 73 b3 ec 75 b5 d0 a6 b9 15 97
412
+                    65 09 13 7d 94 21 d1 fb 5d 0f 8b 23 04 77 c2 c3
413
+                    55 22 b1 a0 09 8a f5 38 2a d6 7f 1b 87 29 a0 25
414
+                    d3 25 6f cb 64 61 07 98 dc 14 c5 84 f8 92 24 5e
415
+                    50 11 6b 49 e5 f0 cc 29 cb 29 a9 19 d8 a7 71 1f
416
+                    91 0b 05 b1 01 4b c2 5f 00 a5 b6 21 bf f8 2c 9d
417
+                    67 9b 47 3b 0a 49 6b 79 2d fc 1d ec 0c b0 e5 27
418
+                    22 d5 a9 f8 d3 c3 f9 df 48 68 e9 fb ef 3c dc 26
419
+                    bf cf ea 29 43 01 a6 e3 c5 51 95 f4 66 6d 8a 55
420
+                    e2 47 ec e8 30 45 4c ae 47 e7 c9 a4 21 8b 64 ba
421
+                    b6 88 f6 21 f8 73 b9 cb 11 a1 78 75 92 c6 5a e5
422
+                    64 fe ed 42 d9 95 99 e6 2b 6f 3c 16 3c 28 74 a4
423
+                    72 2f 0d 3f 2c 33 67 aa 35 19 8e e7 b5 11 2f b3
424
+                    f7 6a c5 02 e2 6f a3 42 e3 62 19 99 03 ea a5 20
425
+                    e7 a1 e3 bc c8 06 a3 b5 7c d6 76 5d df 6f 60 46
426
+                    83 2a 08 00 d6 d3 d9 a4 c1 41 8c f8 60 56 45 81
427
+                    da 3b a2 16 1f 9e 4e 75 83 17 da c3 53 c3 3e 19
428
+                    a4 1b bc d2 29 b8 78 61 2b 78 e6 b1 52 b0 d5 ec
429
+                    de 69 2c 48 62 d9 fd d1 9b 6b b0 49 db d3 ff 38
430
+                    e7 10 d9 2d ce 9f 0d 5e 09 7b 37 d2 7b c3 bf ce
431
+"""),
432
+                derived_passphrase=rb"""ohB8Lva7U6h0KqEZma2Bvnmc7dadCU5uxRhIM5B3mWj3ngNazU4Y64l9haLurkqS9m/Ouf6GfyprMdpuGv6ipYi4RH+hdnOz7HW10Ka5FZdlCRN9lCHR+10PiyMEd8LDVSKxoAmK9Tgq1n8bhymgJdMlb8tkYQeY3BTFhPiSJF5QEWtJ5fDMKcspqRnYp3EfkQsFsQFLwl8ApbYhv/gsnWebRzsKSWt5Lfwd7Ayw5Sci1an408P530ho6fvvPNwmv8/qKUMBpuPFUZX0Zm2KVeJH7OgwRUyuR+fJpCGLZLq2iPYh+HO5yxGheHWSxlrlZP7tQtmVmeYrbzwWPCh0pHIvDT8sM2eqNRmO57URL7P3asUC4m+jQuNiGZkD6qUg56HjvMgGo7V81nZd329gRoMqCADW09mkwUGM+GBWRYHaO6IWH55OdYMX2sNTwz4ZpBu80im4eGEreOaxUrDV7N5pLEhi2f3Rm2uwSdvT/zjnENktzp8NXgl7N9J7w7/O""",
433
+            ),
434
+        },
435
+    ),
436
+    "dsa1024": SSHTestKey(
437
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
438
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH
439
+NzAAAAgQC7KAZXqBGNVLBQPrcMYAoNW54BhD8aIhe7BDWYzJcsaMt72VKSkguZ8+XR7nRa
440
+0C/ZsBi+uJp0dpxy9ZMTOWX4u5YPMeQcXEdGExZIfimGqSOAsy6fCld2IfJZJZExcCmhe9
441
+Ssjsd3YSAPJRluOXFQc95MZoR5hMwlIDD8QzrE7QAAABUA99nOZOgd7aHMVGoXpUEBcn7H
442
+ossAAACALr2Ag3hxM3rKdxzVUw8fX0VVPXO+3+Kr8hGe0Kc/7NwVaBVL1GQ8fenBuWynpA
443
+UbH0wo3h1wkB/8hX6p+S8cnu5rIBlUuVNwLw/bIYohK98LfqTYK/V+g6KD+8m34wvEiXZm
444
+qywY54n2bksch1Nqvj/tNpLzExSx/XS0kSM1aigAAACAbQNRPcVEuGDrEcf+xg5tgAejPX
445
+BPXr/Jss+Chk64km3mirMYjAWyWYtVcgT+7hOYxtYRin8LyMLqKRmqa0Q5UrvDfChgLhvs
446
+G9YSb/Mpw5qm8PiHSafwhkaz/te3+8hKogqoe7sd+tCF06IpJr5k70ACiNtRGqssNF8Elr
447
+l1efYAAAH4swlfVrMJX1YAAAAHc3NoLWRzcwAAAIEAuygGV6gRjVSwUD63DGAKDVueAYQ/
448
+GiIXuwQ1mMyXLGjLe9lSkpILmfPl0e50WtAv2bAYvriadHaccvWTEzll+LuWDzHkHFxHRh
449
+MWSH4phqkjgLMunwpXdiHyWSWRMXApoXvUrI7Hd2EgDyUZbjlxUHPeTGaEeYTMJSAw/EM6
450
+xO0AAAAVAPfZzmToHe2hzFRqF6VBAXJ+x6LLAAAAgC69gIN4cTN6yncc1VMPH19FVT1zvt
451
+/iq/IRntCnP+zcFWgVS9RkPH3pwblsp6QFGx9MKN4dcJAf/IV+qfkvHJ7uayAZVLlTcC8P
452
+2yGKISvfC36k2Cv1foOig/vJt+MLxIl2ZqssGOeJ9m5LHIdTar4/7TaS8xMUsf10tJEjNW
453
+ooAAAAgG0DUT3FRLhg6xHH/sYObYAHoz1wT16/ybLPgoZOuJJt5oqzGIwFslmLVXIE/u4T
454
+mMbWEYp/C8jC6ikZqmtEOVK7w3woYC4b7BvWEm/zKcOapvD4h0mn8IZGs/7Xt/vISqIKqH
455
+u7HfrQhdOiKSa+ZO9AAojbURqrLDRfBJa5dXn2AAAAFQDJHfenj4EJ9WkehpdJatPBlqCW
456
+0gAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UBAgMEBQYH
457
+-----END OPENSSH PRIVATE KEY-----
458
+""",
459
+        private_key_blob=bytes.fromhex("""
460
+            00 00 00 07 73 73 68 2d 64 73 73
461
+            00 00 00 81 00
462
+            bb 28 06 57 a8 11 8d 54 b0 50 3e b7 0c 60 0a 0d
463
+            5b 9e 01 84 3f 1a 22 17 bb 04 35 98 cc 97 2c 68
464
+            cb 7b d9 52 92 92 0b 99 f3 e5 d1 ee 74 5a d0 2f
465
+            d9 b0 18 be b8 9a 74 76 9c 72 f5 93 13 39 65 f8
466
+            bb 96 0f 31 e4 1c 5c 47 46 13 16 48 7e 29 86 a9
467
+            23 80 b3 2e 9f 0a 57 76 21 f2 59 25 91 31 70 29
468
+            a1 7b d4 ac 8e c7 77 61 20 0f 25 19 6e 39 71 50
469
+            73 de 4c 66 84 79 84 cc 25 20 30 fc 43 3a c4 ed
470
+            00 00 00 15 00 f7 d9 ce 64
471
+            e8 1d ed a1 cc 54 6a 17 a5 41 01 72 7e c7 a2 cb
472
+            00 00 00 80
473
+            2e bd 80 83 78 71 33 7a ca 77 1c d5 53 0f 1f 5f
474
+            45 55 3d 73 be df e2 ab f2 11 9e d0 a7 3f ec dc
475
+            15 68 15 4b d4 64 3c 7d e9 c1 b9 6c a7 a4 05 1b
476
+            1f 4c 28 de 1d 70 90 1f fc 85 7e a9 f9 2f 1c 9e
477
+            ee 6b 20 19 54 b9 53 70 2f 0f db 21 8a 21 2b df
478
+            0b 7e a4 d8 2b f5 7e 83 a2 83 fb c9 b7 e3 0b c4
479
+            89 76 66 ab 2c 18 e7 89 f6 6e 4b 1c 87 53 6a be
480
+            3f ed 36 92 f3 13 14 b1 fd 74 b4 91 23 35 6a 28
481
+            00 00 00 80
482
+            6d 03 51 3d c5 44 b8 60 eb 11 c7 fe c6 0e 6d 80
483
+            07 a3 3d 70 4f 5e bf c9 b2 cf 82 86 4e b8 92 6d
484
+            e6 8a b3 18 8c 05 b2 59 8b 55 72 04 fe ee 13 98
485
+            c6 d6 11 8a 7f 0b c8 c2 ea 29 19 aa 6b 44 39 52
486
+            bb c3 7c 28 60 2e 1b ec 1b d6 12 6f f3 29 c3 9a
487
+            a6 f0 f8 87 49 a7 f0 86 46 b3 fe d7 b7 fb c8 4a
488
+            a2 0a a8 7b bb 1d fa d0 85 d3 a2 29 26 be 64 ef
489
+            40 02 88 db 51 1a ab 2c 34 5f 04 96 b9 75 79 f6
490
+            00 00 00 15 00 c9 1d f7 a7
491
+            8f 81 09 f5 69 1e 86 97 49 6a d3 c1 96 a0 96 d2
492
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
493
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
494
+"""),
495
+        public_key=rb"""ssh-dss AAAAB3NzaC1kc3MAAACBALsoBleoEY1UsFA+twxgCg1bngGEPxoiF7sENZjMlyxoy3vZUpKSC5nz5dHudFrQL9mwGL64mnR2nHL1kxM5Zfi7lg8x5BxcR0YTFkh+KYapI4CzLp8KV3Yh8lklkTFwKaF71KyOx3dhIA8lGW45cVBz3kxmhHmEzCUgMPxDOsTtAAAAFQD32c5k6B3tocxUahelQQFyfseiywAAAIAuvYCDeHEzesp3HNVTDx9fRVU9c77f4qvyEZ7Qpz/s3BVoFUvUZDx96cG5bKekBRsfTCjeHXCQH/yFfqn5Lxye7msgGVS5U3AvD9shiiEr3wt+pNgr9X6DooP7ybfjC8SJdmarLBjnifZuSxyHU2q+P+02kvMTFLH9dLSRIzVqKAAAAIBtA1E9xUS4YOsRx/7GDm2AB6M9cE9ev8myz4KGTriSbeaKsxiMBbJZi1VyBP7uE5jG1hGKfwvIwuopGaprRDlSu8N8KGAuG+wb1hJv8ynDmqbw+IdJp/CGRrP+17f7yEqiCqh7ux360IXToikmvmTvQAKI21Eaqyw0XwSWuXV59g== test key without passphrase
496
+""",
497
+        public_key_data=bytes.fromhex("""
498
+            00 00 00 07 73 73 68 2d 64 73 73
499
+            00 00 00 81 00
500
+            bb 28 06 57 a8 11 8d 54 b0 50 3e b7 0c 60 0a 0d
501
+            5b 9e 01 84 3f 1a 22 17 bb 04 35 98 cc 97 2c 68
502
+            cb 7b d9 52 92 92 0b 99 f3 e5 d1 ee 74 5a d0 2f
503
+            d9 b0 18 be b8 9a 74 76 9c 72 f5 93 13 39 65 f8
504
+            bb 96 0f 31 e4 1c 5c 47 46 13 16 48 7e 29 86 a9
505
+            23 80 b3 2e 9f 0a 57 76 21 f2 59 25 91 31 70 29
506
+            a1 7b d4 ac 8e c7 77 61 20 0f 25 19 6e 39 71 50
507
+            73 de 4c 66 84 79 84 cc 25 20 30 fc 43 3a c4 ed
508
+            00 00 00 15 00 f7 d9 ce 64
509
+            e8 1d ed a1 cc 54 6a 17 a5 41 01 72 7e c7 a2 cb
510
+            00 00 00 80
511
+            2e bd 80 83 78 71 33 7a ca 77 1c d5 53 0f 1f 5f
512
+            45 55 3d 73 be df e2 ab f2 11 9e d0 a7 3f ec dc
513
+            15 68 15 4b d4 64 3c 7d e9 c1 b9 6c a7 a4 05 1b
514
+            1f 4c 28 de 1d 70 90 1f fc 85 7e a9 f9 2f 1c 9e
515
+            ee 6b 20 19 54 b9 53 70 2f 0f db 21 8a 21 2b df
516
+            0b 7e a4 d8 2b f5 7e 83 a2 83 fb c9 b7 e3 0b c4
517
+            89 76 66 ab 2c 18 e7 89 f6 6e 4b 1c 87 53 6a be
518
+            3f ed 36 92 f3 13 14 b1 fd 74 b4 91 23 35 6a 28
519
+            00 00 00 80
520
+            6d 03 51 3d c5 44 b8 60 eb 11 c7 fe c6 0e 6d 80
521
+            07 a3 3d 70 4f 5e bf c9 b2 cf 82 86 4e b8 92 6d
522
+            e6 8a b3 18 8c 05 b2 59 8b 55 72 04 fe ee 13 98
523
+            c6 d6 11 8a 7f 0b c8 c2 ea 29 19 aa 6b 44 39 52
524
+            bb c3 7c 28 60 2e 1b ec 1b d6 12 6f f3 29 c3 9a
525
+            a6 f0 f8 87 49 a7 f0 86 46 b3 fe d7 b7 fb c8 4a
526
+            a2 0a a8 7b bb 1d fa d0 85 d3 a2 29 26 be 64 ef
527
+            40 02 88 db 51 1a ab 2c 34 5f 04 96 b9 75 79 f6
528
+"""),
529
+        expected_signatures={
530
+            SSHTestKeyDeterministicSignatureClass.RFC_6979: SSHTestKeyDeterministicSignature(
531
+                signature=bytes.fromhex("""
532
+                    00 00 00 07 73 73 68 2d 64 73 73
533
+                    00 00 00 28 11 5f 4d 13 c2 ee 61 97
534
+                    1e f6 23 14 3b 2b dd cf 06 c0 71 13 cc ac 34 19
535
+                    ad 36 8d 79 aa 25 fb 5e 4f ea fe 6b 5b fa 57 42
536
+"""),
537
+                derived_passphrase=rb"""EV9NE8LuYZce9iMUOyvdzwbAcRPMrDQZrTaNeaol+15P6v5rW/pXQg==""",
538
+                signature_class=SSHTestKeyDeterministicSignatureClass.RFC_6979,
539
+            ),
540
+            SSHTestKeyDeterministicSignatureClass.Pageant_068_080: SSHTestKeyDeterministicSignature(
541
+                signature=bytes.fromhex("""
542
+                    00 00 00 07 73 73 68 2d 64 73 73
543
+                    00 00 00 28 0b f7 a8 ab 89 f5 b6 c4
544
+                    1c 9b 78 2c 46 35 69 e2 88 b7 eb 55 37 48 7f 6d
545
+                    49 a1 e6 de 58 1a 04 eb e6 28 99 0e 3c fd 3b 48
546
+"""),
547
+                derived_passphrase=rb"""C/eoq4n1tsQcm3gsRjVp4oi361U3SH9tSaHm3lgaBOvmKJkOPP07SA==""",
548
+                signature_class=SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
549
+            ),
550
+        },
551
+    ),
552
+    "ecdsa256": SSHTestKey(
553
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
554
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
555
+1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTLbU0zDwsk2Dvp+VYIrsNVf5gWwz2S
556
+3SZ8TbxiQRkpnGSVqyIoHJOJc+NQItAa7xlJ/8Z6gfz57Z3apUkaMJm6AAAAuKeY+YinmP
557
+mIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5
558
+Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmb
559
+oAAAAhAKIl/3n0pKVIxpZkXTGtii782Qr4yIcvHdpxjO/QsIqKAAAAG3Rlc3Qga2V5IHdp
560
+dGhvdXQgcGFzc3BocmFzZQECAwQ=
561
+-----END OPENSSH PRIVATE KEY-----
562
+""",
563
+        private_key_blob=bytes.fromhex("""
564
+            00 00 00 13 65 63 64
565
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 32 35 36
566
+            00 00 00 08 6e 69 73 74 70 32 35 36
567
+            00 00 00 41 04
568
+            cb 6d 4d 33 0f 0b 24 d8 3b e9 f9 56 08 ae c3 55
569
+            7f 98 16 c3 3d 92 dd 26 7c 4d bc 62 41 19 29 9c
570
+            64 95 ab 22 28 1c 93 89 73 e3 50 22 d0 1a ef 19
571
+            49 ff c6 7a 81 fc f9 ed 9d da a5 49 1a 30 99 ba
572
+            00 00 00 21 00
573
+            a2 25 ff 79 f4 a4 a5 48 c6 96 64 5d 31 ad 8a 2e
574
+            fc d9 0a f8 c8 87 2f 1d da 71 8c ef d0 b0 8a 8a
575
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
576
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
577
+"""),
578
+        public_key=rb"""ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmbo= test key without passphrase
579
+""",
580
+        public_key_data=bytes.fromhex("""
581
+            00 00 00 13 65 63 64
582
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 32 35 36
583
+            00 00 00 08 6e 69 73 74 70 32 35 36
584
+            00 00 00 41 04
585
+            cb 6d 4d 33 0f 0b 24 d8 3b e9 f9 56 08 ae c3 55
586
+            7f 98 16 c3 3d 92 dd 26 7c 4d bc 62 41 19 29 9c
587
+            64 95 ab 22 28 1c 93 89 73 e3 50 22 d0 1a ef 19
588
+            49 ff c6 7a 81 fc f9 ed 9d da a5 49 1a 30 99 ba
589
+"""),
590
+        expected_signatures={
591
+            SSHTestKeyDeterministicSignatureClass.RFC_6979: SSHTestKeyDeterministicSignature(
592
+                signature=bytes.fromhex("""
593
+                    00 00 00 13 65 63 64
594
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 32 35 36
595
+                    00 00 00 49
596
+                    00 00 00 20
597
+                    22 ad 23 8a 9c 5d ca 4e ea 73 e7 29 77 ab a8 b2
598
+                    2e 01 d8 de 11 ae c9 b3 57 ce d5 84 9c 85 73 eb
599
+                    00 00 00 21 00
600
+                    9b 1a cb dd 45 89 f0 37 95 9c a2 d8 ac c3 f7 71
601
+                    55 33 50 86 9e cb 3a 95 e4 68 80 1a 9d d6 d5 bc
602
+"""),
603
+                derived_passphrase=rb"""AAAAICKtI4qcXcpO6nPnKXerqLIuAdjeEa7Js1fO1YSchXPrAAAAIQCbGsvdRYnwN5Wcotisw/dxVTNQhp7LOpXkaIAandbVvA==""",
604
+                signature_class=SSHTestKeyDeterministicSignatureClass.RFC_6979,
605
+            ),
606
+            SSHTestKeyDeterministicSignatureClass.Pageant_068_080: SSHTestKeyDeterministicSignature(
607
+                signature=bytes.fromhex("""
608
+                    00 00 00 13 65 63 64
609
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 32 35 36
610
+                    00 00 00 49
611
+                    00 00 00 21 00
612
+                    b7 9e 4f ec ec 9b 77 dd 12 d9 43 a2 f5 bf b5 34
613
+                    91 e0 89 44 e6 20 48 36 fa 75 22 77 86 38 de 21
614
+                    00 00 00 20
615
+                    3f d8 04 0f fa f5 bc d2 26 e0 4c 0c 77 5d 0e 08
616
+                    ec 30 04 8e 42 58 41 96 f6 7e 4f d2 14 39 f4 87
617
+"""),
618
+                derived_passphrase=rb"""AAAAIQC3nk/s7Jt33RLZQ6L1v7U0keCJROYgSDb6dSJ3hjjeIQAAACA/2AQP+vW80ibgTAx3XQ4I7DAEjkJYQZb2fk/SFDn0hw==""",
619
+                signature_class=SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
620
+            ),
621
+        },
622
+    ),
623
+    "ecdsa384": SSHTestKey(
624
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
625
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS
626
+1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSgkOjkAvq7v5vHuj3KBL4/EAWcn5hZ
627
+DyKcbyV0eBMGFq7hKXQlZqIahLVqeMR0QqmkxNJ2rly2VHcXneq3vZ+9fIsWCOdYk5WP3N
628
+ZPzv911Xn7wbEkC7QndD5zKlm4pBUAAADomhj+IZoY/iEAAAATZWNkc2Etc2hhMi1uaXN0
629
+cDM4NAAAAAhuaXN0cDM4NAAAAGEEoJDo5AL6u7+bx7o9ygS+PxAFnJ+YWQ8inG8ldHgTBh
630
+au4Sl0JWaiGoS1anjEdEKppMTSdq5ctlR3F53qt72fvXyLFgjnWJOVj9zWT87/ddV5+8Gx
631
+JAu0J3Q+cypZuKQVAAAAMQD5sTy8p+B1cn/DhOmXquui1BcxvASqzzevkBlbQoBa73y04B
632
+2OdqVOVRkwZWRROz0AAAAbdGVzdCBrZXkgd2l0aG91dCBwYXNzcGhyYXNlAQIDBA==
633
+-----END OPENSSH PRIVATE KEY-----
634
+""",
635
+        private_key_blob=bytes.fromhex("""
636
+            00 00 00 13 65 63 64
637
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 33 38 34
638
+            00 00 00 08 6e 69 73 74 70 33 38 34
639
+            00 00 00 61 04
640
+            a0 90 e8 e4 02 fa bb bf 9b c7 ba 3d ca 04 be 3f
641
+            10 05 9c 9f 98 59 0f 22 9c 6f 25 74 78 13 06 16
642
+            ae e1 29 74 25 66 a2 1a 84 b5 6a 78 c4 74 42 a9
643
+            a4 c4 d2 76 ae 5c b6 54 77 17 9d ea b7 bd 9f bd
644
+            7c 8b 16 08 e7 58 93 95 8f dc d6 4f ce ff 75 d5
645
+            79 fb c1 b1 24 0b b4 27 74 3e 73 2a 59 b8 a4 15
646
+            00 00 00 31 00
647
+            f9 b1 3c bc a7 e0 75 72 7f c3 84 e9 97 aa eb a2
648
+            d4 17 31 bc 04 aa cf 37 af 90 19 5b 42 80 5a ef
649
+            7c b4 e0 1d 8e 76 a5 4e 55 19 30 65 64 51 3b 3d
650
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
651
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
652
+"""),
653
+        public_key=rb"""ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBKCQ6OQC+ru/m8e6PcoEvj8QBZyfmFkPIpxvJXR4EwYWruEpdCVmohqEtWp4xHRCqaTE0nauXLZUdxed6re9n718ixYI51iTlY/c1k/O/3XVefvBsSQLtCd0PnMqWbikFQ== test key without passphrase
654
+""",
655
+        public_key_data=bytes.fromhex("""
656
+            00 00 00 13 65 63 64
657
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 33 38 34
658
+            00 00 00 08 6e 69 73 74 70 33 38 34
659
+            00 00 00 61 04
660
+            a0 90 e8 e4 02 fa bb bf 9b c7 ba 3d ca 04 be 3f
661
+            10 05 9c 9f 98 59 0f 22 9c 6f 25 74 78 13 06 16
662
+            ae e1 29 74 25 66 a2 1a 84 b5 6a 78 c4 74 42 a9
663
+            a4 c4 d2 76 ae 5c b6 54 77 17 9d ea b7 bd 9f bd
664
+            7c 8b 16 08 e7 58 93 95 8f dc d6 4f ce ff 75 d5
665
+            79 fb c1 b1 24 0b b4 27 74 3e 73 2a 59 b8 a4 15
666
+"""),
667
+        expected_signatures={
668
+            SSHTestKeyDeterministicSignatureClass.RFC_6979: SSHTestKeyDeterministicSignature(
669
+                signature=bytes.fromhex("""
670
+                    00 00 00 13 65 63 64
671
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 33 38 34
672
+                    00 00 00 68
673
+                    00 00 00 30
674
+                    78 e1 a8 f5 8c d2 7a 21 e5 a2 ca e6 d0 1a 19 f8
675
+                    3a 1c 39 7e 71 a0 e6 7e 93 83 49 95 05 01 d0 3e
676
+                    23 22 cd 09 63 7f 7c 6c b0 97 44 6d 7e 48 39 87
677
+                    00 00 00 30
678
+                    10 ee 85 51 77 2b 91 2c e9 42 79 66 59 8a a2 c0
679
+                    d2 c8 8a 8f 2f 8f 33 87 9e 12 54 e4 da 02 f9 e7
680
+                    95 f5 82 6f 82 2b 38 6d 6e 5d 17 15 ac 12 e7 62
681
+"""),
682
+                derived_passphrase=rb"""AAAAMHjhqPWM0noh5aLK5tAaGfg6HDl+caDmfpODSZUFAdA+IyLNCWN/fGywl0Rtfkg5hwAAADAQ7oVRdyuRLOlCeWZZiqLA0siKjy+PM4eeElTk2gL555X1gm+CKzhtbl0XFawS52I=""",
683
+                signature_class=SSHTestKeyDeterministicSignatureClass.RFC_6979,
684
+            ),
685
+            SSHTestKeyDeterministicSignatureClass.Pageant_068_080: SSHTestKeyDeterministicSignature(
686
+                signature=bytes.fromhex("""
687
+                    00 00 00 13 65 63 64
688
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 33 38 34
689
+                    00 00 00 69
690
+                    00 00 00 30
691
+                    4b 3e b7 22 c2 87 77 6d e0 3e f5 05 75 36 b6 0f
692
+                    cd 9f a4 49 c7 48 ef 76 fd ea 4b 49 e3 b1 f2 22
693
+                    d5 41 22 d7 96 b2 29 70 ff bb 81 97 27 e2 35 60
694
+                    00 00 00 31 00
695
+                    c8 a4 d8 62 fe f2 a6 63 97 98 08 c7 39 24 b2 55
696
+                    0a b8 e7 79 ab a6 62 96 3e cc ea 73 e2 fb dc 46
697
+                    d6 25 b9 c8 0c e8 3e 33 91 51 78 25 a8 c5 46 85
698
+"""),
699
+                derived_passphrase=rb"""AAAAMEs+tyLCh3dt4D71BXU2tg/Nn6RJx0jvdv3qS0njsfIi1UEi15ayKXD/u4GXJ+I1YAAAADEAyKTYYv7ypmOXmAjHOSSyVQq453mrpmKWPszqc+L73EbWJbnIDOg+M5FReCWoxUaF""",
700
+                signature_class=SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
701
+            ),
702
+        },
703
+    ),
704
+    "ecdsa521": SSHTestKey(
705
+        private_key=rb"""-----BEGIN OPENSSH PRIVATE KEY-----
706
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS
707
+1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQASVOdwDznmlcGqiLvFtYeVtrAEiVz
708
+iIfsL7jEM8Utu/m8WSkPFQtjwqdFw+WfZ0mi6qMbEFgi/ELzZSKVteCSbcMAhqAkOMFKiD
709
+u4bxvsM6bT02Ru7q2yT41ySyGhUD0QySBnI6Ckt/wnQ1TEpj8zDKiRErxs9e6QLGElNRkz
710
+LPMs+mMAAAEY2FXeh9hV3ocAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ
711
+AAAIUEAElTncA855pXBqoi7xbWHlbawBIlc4iH7C+4xDPFLbv5vFkpDxULY8KnRcPln2dJ
712
+ouqjGxBYIvxC82UilbXgkm3DAIagJDjBSog7uG8b7DOm09Nkbu6tsk+NckshoVA9EMkgZy
713
+OgpLf8J0NUxKY/MwyokRK8bPXukCxhJTUZMyzzLPpjAAAAQSFqUmKK7lGQzxT6GKZSLDju
714
+U3otwLYnuj+/5AdzuB/zotu95UdFv9I2DNXzd9E4WAyz6IqBBNcsMkxrzHAdqsYDAAAAG3
715
+Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQ==
716
+-----END OPENSSH PRIVATE KEY-----
717
+""",
718
+        private_key_blob=bytes.fromhex("""
719
+            00 00 00 13 65 63 64
720
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 35 32 31
721
+            00 00 00 08 6e 69 73 74 70 35 32 31
722
+            00 00 00 85 04 00 49 53 9d
723
+            c0 3c e7 9a 57 06 aa 22 ef 16 d6 1e 56 da c0 12
724
+            25 73 88 87 ec 2f b8 c4 33 c5 2d bb f9 bc 59 29
725
+            0f 15 0b 63 c2 a7 45 c3 e5 9f 67 49 a2 ea a3 1b
726
+            10 58 22 fc 42 f3 65 22 95 b5 e0 92 6d c3 00 86
727
+            a0 24 38 c1 4a 88 3b b8 6f 1b ec 33 a6 d3 d3 64
728
+            6e ee ad b2 4f 8d 72 4b 21 a1 50 3d 10 c9 20 67
729
+            23 a0 a4 b7 fc 27 43 54 c4 a6 3f 33 0c a8 91 12
730
+            bc 6c f5 ee 90 2c 61 25 35 19 33 2c f3 2c fa 63
731
+            00 00 00 41 21
732
+            6a 52 62 8a ee 51 90 cf 14 fa 18 a6 52 2c 38 ee
733
+            53 7a 2d c0 b6 27 ba 3f bf e4 07 73 b8 1f f3 a2
734
+            db bd e5 47 45 bf d2 36 0c d5 f3 77 d1 38 58 0c
735
+            b3 e8 8a 81 04 d7 2c 32 4c 6b cc 70 1d aa c6 03
736
+            00 00 00 1b 74 65 73 74 20 6b 65 79 20 77 69
737
+            74 68 6f 75 74 20 70 61 73 73 70 68 72 61 73 65
738
+"""),
739
+        public_key=rb"""ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBABJU53APOeaVwaqIu8W1h5W2sASJXOIh+wvuMQzxS27+bxZKQ8VC2PCp0XD5Z9nSaLqoxsQWCL8QvNlIpW14JJtwwCGoCQ4wUqIO7hvG+wzptPTZG7urbJPjXJLIaFQPRDJIGcjoKS3/CdDVMSmPzMMqJESvGz17pAsYSU1GTMs8yz6Yw== test key without passphrase
740
+""",
741
+        public_key_data=bytes.fromhex("""
742
+            00 00 00 13 65 63 64
743
+            73 61 2d 73 68 61 32 2d 6e 69 73 74 70 35 32 31
744
+            00 00 00 08 6e 69 73 74 70 35 32 31
745
+            00 00 00 85 04 00 49 53 9d
746
+            c0 3c e7 9a 57 06 aa 22 ef 16 d6 1e 56 da c0 12
747
+            25 73 88 87 ec 2f b8 c4 33 c5 2d bb f9 bc 59 29
748
+            0f 15 0b 63 c2 a7 45 c3 e5 9f 67 49 a2 ea a3 1b
749
+            10 58 22 fc 42 f3 65 22 95 b5 e0 92 6d c3 00 86
750
+            a0 24 38 c1 4a 88 3b b8 6f 1b ec 33 a6 d3 d3 64
751
+            6e ee ad b2 4f 8d 72 4b 21 a1 50 3d 10 c9 20 67
752
+            23 a0 a4 b7 fc 27 43 54 c4 a6 3f 33 0c a8 91 12
753
+            bc 6c f5 ee 90 2c 61 25 35 19 33 2c f3 2c fa 63
754
+"""),
755
+        expected_signatures={
756
+            SSHTestKeyDeterministicSignatureClass.RFC_6979: SSHTestKeyDeterministicSignature(
757
+                signature=bytes.fromhex("""
758
+                    00 00 00 13 65 63 64
759
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 35 32 31
760
+                    00 00 00 8b
761
+                    00 00 00 42 01 d8
762
+                    ea c2 1e 55 c6 9e dd 4b 00 ed 1b 93 19 cc 9b 74
763
+                    27 44 c0 c0 e3 5b 3d 81 15 00 12 cc 07 89 54 97
764
+                    ec 60 42 ad e6 40 c1 c6 5f c0 1b c3 0a 8e 58 6e
765
+                    da 3f a9 57 90 04 79 46 1d 48 bb 19 67 e9 65 19
766
+                    00 00 00 41 7d
767
+                    58 e0 2e d7 86 2e 36 8c 1a 44 23 af 19 e7 51 97
768
+                    bb fb 32 90 a1 35 bb 88 d7 b5 22 37 b3 99 ba e4
769
+                    a7 9d 2d 56 14 0a f5 68 f5 cc 38 84 e9 b6 c6 71
770
+                    7a 3b 87 e7 7a b1 37 e7 1d e6 80 96 d1 a6 1e bc
771
+"""),
772
+                derived_passphrase=rb"""AAAAQgHY6sIeVcae3UsA7RuTGcybdCdEwMDjWz2BFQASzAeJVJfsYEKt5kDBxl/AG8MKjlhu2j+pV5AEeUYdSLsZZ+llGQAAAEF9WOAu14YuNowaRCOvGedRl7v7MpChNbuI17UiN7OZuuSnnS1WFAr1aPXMOITptsZxejuH53qxN+cd5oCW0aYevA==""",
773
+                signature_class=SSHTestKeyDeterministicSignatureClass.RFC_6979,
774
+            ),
775
+            SSHTestKeyDeterministicSignatureClass.Pageant_068_080: SSHTestKeyDeterministicSignature(
776
+                signature=bytes.fromhex("""
777
+                    00 00 00 13 65 63 64
778
+                    73 61 2d 73 68 61 32 2d 6e 69 73 74 70 35 32 31
779
+                    00 00 00 8c
780
+                    00 00 00 42 01 ce
781
+                    fe 9d 66 b6 01 76 2e 86 c2 ab 68 62 73 44 05 23
782
+                    fd d1 79 07 fc 45 f5 c0 83 36 88 61 d4 04 79 90
783
+                    b0 ef 8b 3c b5 55 0e cc 26 6b a0 3e 6a 04 48 ca
784
+                    e4 6a a5 a0 cf 91 5f 71 6f 37 9a 0f 6b a9 fb 9b
785
+                    00 00 00 42 01 6d
786
+                    21 77 c6 13 fa ea ac de 90 19 24 5a d2 61 39 d9
787
+                    66 9b 86 1a 41 04 58 a2 9b b8 93 b6 6f 82 23 f2
788
+                    01 23 c7 ff 5a d3 86 95 0f da 28 f9 3b e3 9c 27
789
+                    e7 b2 d7 66 4e 5f 38 36 4c 8c be 76 4e fa 0a 2d
790
+"""),
791
+                derived_passphrase=rb"""AAAAQgHO/p1mtgF2LobCq2hic0QFI/3ReQf8RfXAgzaIYdQEeZCw74s8tVUOzCZroD5qBEjK5GqloM+RX3FvN5oPa6n7mwAAAEIBbSF3xhP66qzekBkkWtJhOdlmm4YaQQRYopu4k7ZvgiPyASPH/1rThpUP2ij5O+OcJ+ey12ZOXzg2TIy+dk76Ci0=""",
792
+                signature_class=SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
793
+            ),
794
+        },
795
+    ),
796
+}
797
+"""The master list of SSH test keys."""
798
+SUPPORTED_KEYS: Mapping[str, SSHTestKey] = {
799
+    k: v
800
+    for k, v in ALL_KEYS.items()
801
+    if SSHTestKeyDeterministicSignatureClass.SPEC
802
+    in v.expected_signatures
803
+}
804
+"""The subset of SSH test keys suitable for use with vault.
805
+
806
+Suitability is tested via the presence the `SPEC` expected signature
807
+class.
808
+
809
+"""
810
+CONDITIONALLY_SUPPORTED_KEYS: Mapping[str, SSHTestKey] = {
811
+    k: v
812
+    for k, v in ALL_KEYS.items()
813
+    if k not in SUPPORTED_KEYS
814
+    and (
815
+        SSHTestKeyDeterministicSignatureClass.RFC_6979
816
+        in v.expected_signatures
817
+        or SSHTestKeyDeterministicSignatureClass.Pageant_068_080
818
+        in v.expected_signatures
819
+    )
820
+}
821
+"""The subset of SSH test keys suitable for use with vault.
822
+
823
+Suitability is tested via the presence the `SPEC` expected signature
824
+class.
825
+
826
+"""
827
+UNSUITABLE_KEYS: Mapping[str, SSHTestKey] = {
828
+    k: v
829
+    for k, v in ALL_KEYS.items()
830
+    if k not in SUPPORTED_KEYS and k not in CONDITIONALLY_SUPPORTED_KEYS
831
+}
832
+"""The subset of SSH test keys not suitable for use with vault.
833
+
834
+Suitability is tested via the absence of the `SPEC`, `RFC_6979` and
835
+`Pageant_068_080` expected signature classes.
836
+
837
+"""
... ...
@@ -0,0 +1,468 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Machinery for the fake SSH agent."""
6
+
7
+from __future__ import annotations
8
+
9
+import errno
10
+import os
11
+import struct
12
+from typing import TYPE_CHECKING, cast
13
+
14
+import derivepassphrase_sshagentsocketprovider as d_sasp
15
+from fakesshagent import _types, data
16
+
17
+if TYPE_CHECKING:
18
+    from collections.abc import Generator, Iterable
19
+
20
+    from typing_extensions import Buffer, Self
21
+
22
+__all__ = ("StubbedSSHAgentSocket",)
23
+
24
+VAULT_UUID = b"e87eb0f4-34cb-46b9-93ad-766c5ab063e7"
25
+uint32_format = struct.Struct(">I")
26
+
27
+
28
+def string(bstr: Buffer, /) -> bytes:
29
+    """Return the serialized payload as per the SSH agent protocol."""
30
+    payload = memoryview(bstr)
31
+    buffer = bytearray(payload)
32
+    buffer[:0] = bytes(4)
33
+    uint32_format.pack_into(buffer, 0, len(payload))
34
+    return bytes(buffer)
35
+
36
+
37
+def uint32(i: int, /) -> bytes:
38
+    """Return the serialized integer as per the SSH agent protocol."""
39
+    return uint32_format.pack(i)
40
+
41
+
42
+def unstring_prefix(buf: Buffer, /) -> tuple[bytes, bytes]:
43
+    """Decode a string encoded as per the SSH agent protocol.
44
+
45
+    Any data remaining after the encoded string is returned unchanged.
46
+
47
+    Args:
48
+        buf:
49
+            A binary buffer, beginning with an encoded string.
50
+
51
+    Returns:
52
+        The decoded string, and the remaining contents of the buffer, as
53
+        a 2-tuple.
54
+
55
+    Raises:
56
+        ValueError:
57
+            The encoded string is invalid or truncated.
58
+
59
+    """
60
+    payload = memoryview(buf)
61
+    pos = 0
62
+    try:
63
+        size = cast("tuple[int]", uint32_format.unpack_from(payload, 0))[0]
64
+    except struct.error as exc:
65
+        msg = f"Invalid or truncated SSH protocol string value: {buf!r}"
66
+        raise ValueError(msg) from exc
67
+    pos += uint32_format.size
68
+    head = bytes(payload[pos : pos + size])
69
+    if len(head) < size:
70
+        msg = f"Invalid or truncated SSH protocol string value: {buf!r}"
71
+        raise ValueError(msg)
72
+    tail = bytes(payload[pos + size :])
73
+    return head, tail
74
+
75
+
76
+class StubbedSSHAgentSocket:
77
+    """A stubbed SSH agent presenting an [`_types.SSHAgentSocket`][].
78
+
79
+    On the network protocol side, the agent implements the full
80
+    [`_types.SSHAgentSocket`][] interface, including pipelined and
81
+    unaligned agent requests.  However, on the application side, the
82
+    agent is intrinsically tied to [the set of SSH test
83
+    keys][data.ALL_KEYS], and only gives meaningful answers for
84
+    operations on the test keys and for agent operations in use by
85
+    [`ssh_agent.SSHAgentClient`][].  The agent does not actually
86
+    implement any cryptography; all cryptography-related answers are
87
+    derived from the recorded test key data.
88
+
89
+    It is not safe to further monkeypatch the agent's [`recv`][] or
90
+    [`sendall`][] methods on their own: either monkeypatch both of them,
91
+    or manipulate the [`send_to_client`][] bytes queue directly instead.
92
+    Given an [`ssh_agent.SSHAgentClient`][] connected to
93
+    a [`StubbedSSHAgentSocket`][], if the test ensures proper message
94
+    serialization and protocol framing and if the monkeypatching can be
95
+    expressed in terms of full request messages and full response
96
+    messages, prefer using a [`AgentProtocolResponseQueue`][] to
97
+    monkeypatch the client's high-level request/response-loop instead of
98
+    monkeypatching the low-level socket communication in this agent
99
+    socket.
100
+
101
+    """
102
+
103
+    _NO_FLAG_SUPPORT = "This stubbed SSH agent socket does not support flags."
104
+    _PROTOCOL_VIOLATION = "SSH agent protocol violation."
105
+    _INVALID_REQUEST = "Invalid request."
106
+    _UNSUPPORTED_REQUEST = "Unsupported request."
107
+    _INCOMPLETE_REQUEST = "The last request was incomplete."
108
+
109
+    HEADER_SIZE = 4
110
+    CODE_SIZE = 1
111
+
112
+    KNOWN_EXTENSIONS = frozenset({
113
+        "query",
114
+        "list-extended@putty.projects.tartarus.org",
115
+    })
116
+    """Known and implemented protocol extensions."""
117
+
118
+    def __init__(self, *extensions: str) -> None:
119
+        """Initialize the agent."""
120
+        self.send_to_client = bytearray()
121
+        """
122
+        The buffered response to the client, read piecemeal by [`recv`][].
123
+        """
124
+        self.receive_from_client = bytearray()
125
+        """The last request issued by the client."""
126
+        self.closed = False
127
+        """True if the connection is closed, false otherwise."""
128
+        self.enabled_extensions = frozenset(extensions) & self.KNOWN_EXTENSIONS
129
+        """
130
+        Extensions actually enabled in this particular stubbed SSH agent.
131
+        """
132
+        self.try_rfc6979 = False
133
+        """
134
+        Attempt to issue DSA and ECDSA signatures according to RFC 6979?
135
+        """
136
+        self.try_pageant_068_080 = False
137
+        """
138
+        Attempt to issue DSA and ECDSA signatures as per Pageant 0.68–0.80?
139
+        """  # noqa: RUF001
140
+
141
+    def __enter__(self) -> Self:
142
+        """Return self."""
143
+        return self
144
+
145
+    def __exit__(self, *args: object) -> None:
146
+        """Mark the agent's socket as closed.
147
+
148
+        Raises:
149
+            AssertionError:
150
+                The last request to the agent was incomplete.
151
+
152
+        """
153
+        self.closed = True
154
+        assert not self.receive_from_client, self._INCOMPLETE_REQUEST
155
+
156
+    def sendall(self, data: Buffer, flags: int = 0, /) -> None:
157
+        """Send data to the SSH agent.
158
+
159
+        The signature, and behavior, is identical to
160
+        [`socket.socket.sendall`][].  Upon successful sending, this
161
+        agent will parse the request, call the appropriate handler, and
162
+        buffer the result such that it can be read via [`recv`][], in
163
+        accordance with the SSH agent protocol.
164
+
165
+        Args:
166
+            data: Binary data to send to the agent.
167
+            flags: Reserved.  Must be 0.
168
+
169
+        Raises:
170
+            AssertionError:
171
+                The flags argument, if specified, must be 0.
172
+            OSError:
173
+                The socket connection is already closed.
174
+
175
+        Note:
176
+            The result should be requested via [`recv`][], and
177
+            interpreted in accordance with the SSH agent protocol.
178
+
179
+        """
180
+        assert not flags, self._NO_FLAG_SUPPORT
181
+        self._check_for_io_on_closed_connection()
182
+        self.receive_from_client.extend(memoryview(data))
183
+        while self.receive_from_client:
184
+            result: Buffer | Iterable[int]
185
+            if len(self.receive_from_client) < self.HEADER_SIZE:
186
+                break
187
+            count = int.from_bytes(
188
+                self.receive_from_client[: self.HEADER_SIZE],
189
+                "big",
190
+                signed=False,
191
+            )
192
+            if count:
193
+                code = int.from_bytes(
194
+                    self.receive_from_client[
195
+                        self.HEADER_SIZE : self.HEADER_SIZE + self.CODE_SIZE
196
+                    ],
197
+                    "big",
198
+                    signed=False,
199
+                )
200
+                request = bytes(
201
+                    self.receive_from_client[: self.HEADER_SIZE + count]
202
+                )
203
+                if len(request) < self.HEADER_SIZE + count:
204
+                    break
205
+                request_payload = request[self.HEADER_SIZE + self.CODE_SIZE :]
206
+
207
+                if code == _types.SSH_AGENTC.REQUEST_IDENTITIES:
208
+                    result = self.request_identities(list_extended=False)
209
+                elif code == _types.SSH_AGENTC.SIGN_REQUEST:
210
+                    result = self.sign(request_payload)
211
+                elif self._check_for_extension(code, "query"):
212
+                    result = self.query_extensions()
213
+                elif self._check_for_extension(
214
+                    code, "list-extended@putty.projects.tartarus.org"
215
+                ):
216
+                    result = self.request_identities(list_extended=True)
217
+                else:
218
+                    result = self._failure()
219
+            else:
220
+                request = bytes(self.receive_from_client[: self.HEADER_SIZE])
221
+                result = self._failure()
222
+            self.send_to_client.extend(string(bytes(result)))
223
+            self.receive_from_client[: len(request)] = b""
224
+
225
+    def recv(self, count: int, flags: int = 0, /) -> bytes:
226
+        """Read data from the SSH agent.
227
+
228
+        As per the SSH agent protocol, data is only available to be read
229
+        immediately after a request via [`sendall`][] and if the socket
230
+        connection is still open.  Calls to [`recv`][] at other points
231
+        in time that attempt to read data violate the protocol, and will
232
+        fail.  (A [`recv`][] of zero bytes does not read data.)  Calls
233
+        to [`recv`][] when the socket connection is closed always fail.
234
+
235
+        Args:
236
+            count:
237
+                Number of bytes to read from the agent.
238
+            flags:
239
+                Reserved.  Must be 0.
240
+
241
+        Returns:
242
+            (A chunk of) the SSH agent's response to the most recent
243
+            request.  If reading 0 bytes, the returned chunk is always
244
+            an empty byte string.
245
+
246
+        Raises:
247
+            AssertionError:
248
+                The flags argument, if specified, must be 0.
249
+
250
+                Alternatively, `recv` was called when there was no
251
+                response to be obtained, in violation of the SSH agent
252
+                protocol.
253
+            OSError:
254
+                The socket connection is already closed.
255
+
256
+        """
257
+        assert not flags, self._NO_FLAG_SUPPORT
258
+        self._check_for_io_on_closed_connection()
259
+        assert not count or self.send_to_client, self._PROTOCOL_VIOLATION
260
+        ret = bytes(self.send_to_client[:count])
261
+        del self.send_to_client[:count]
262
+        return ret
263
+
264
+    def _failure(self) -> bytes:  # noqa: PLR6301
265
+        return bytes(_types.SSH_AGENT.FAILURE)
266
+
267
+    def _check_for_io_on_closed_connection(self) -> None:
268
+        if self.closed:
269
+            raise OSError(errno.EBADF, os.strerror(errno.EBADF))
270
+
271
+    def _check_for_extension(self, code: int, extension: str) -> bool:
272
+        if (
273
+            extension not in self.enabled_extensions
274
+            or code != _types.SSH_AGENTC.EXTENSION
275
+        ):
276
+            return False
277
+        extension_marker = b"\x1b" + string(extension.encode("ascii"))
278
+        return self.receive_from_client.startswith(extension_marker, 4)
279
+
280
+    def query_extensions(self) -> Generator[int, None, None]:  # noqa: PLR6301
281
+        """Answer an `SSH_AGENTC_EXTENSION` request.
282
+
283
+        Yields:
284
+            The bytes payload of the response, without the protocol
285
+            framing.  The payload is yielded byte by byte, as an
286
+            iterable of 8-bit integers.
287
+
288
+        """
289
+        yield _types.SSH_AGENT.EXTENSION_RESPONSE
290
+        yield from string(b"query")
291
+        extension_answers = [
292
+            b"query",
293
+            b"list-extended@putty.projects.tartarus.org",
294
+        ]
295
+        for a in extension_answers:
296
+            yield from string(a)
297
+
298
+    def request_identities(
299
+        self, *, list_extended: bool = False
300
+    ) -> Generator[int, None, None]:
301
+        """Answer an `SSH_AGENTC_REQUEST_IDENTITIES` request.
302
+
303
+        Args:
304
+            list_extended:
305
+                If true, answer an `SSH_AGENTC_EXTENSION` request for
306
+                the `list-extended@putty.projects.tartarus.org`
307
+                extension. Otherwise, answer an
308
+                `SSH_AGENTC_REQUEST_IDENTITIES` request.
309
+
310
+        Yields:
311
+            The bytes payload of the response, without the protocol
312
+            framing.  The payload is yielded byte by byte, as an
313
+            iterable of 8-bit integers.
314
+
315
+        """
316
+        if list_extended:
317
+            yield _types.SSH_AGENT.SUCCESS
318
+        else:
319
+            yield _types.SSH_AGENT.IDENTITIES_ANSWER
320
+        signature_classes = [
321
+            data.SSHTestKeyDeterministicSignatureClass.SPEC,
322
+        ]
323
+        if (
324
+            "list-extended@putty.projects.tartarus.org"
325
+            in self.enabled_extensions
326
+        ):
327
+            signature_classes.append(
328
+                data.SSHTestKeyDeterministicSignatureClass.RFC_6979
329
+            )
330
+        keys = [
331
+            v
332
+            for v in data.ALL_KEYS.values()
333
+            if any(cls in v.expected_signatures for cls in signature_classes)
334
+        ]
335
+        yield from uint32(len(keys))
336
+        for key in keys:
337
+            yield from string(key.public_key_data)
338
+            yield from string(b"test key without passphrase")
339
+            if list_extended:
340
+                yield from string(uint32(0))
341
+
342
+    def sign(self, request_payload: bytes, /) -> bytes:  # noqa: PLR0911
343
+        """Answer an `SSH_AGENTC_SIGN_REQUEST` request.
344
+
345
+        Args:
346
+            request_payload:
347
+                The data of the sign request, without the protocol
348
+                framing or the request code.
349
+
350
+        Returns:
351
+            The bytes payload of the response, without the protocol
352
+            framing.
353
+
354
+        """
355
+        try_rfc6979 = (
356
+            "list-extended@putty.projects.tartarus.org"
357
+            in self.enabled_extensions
358
+        )
359
+        spec = data.SSHTestKeyDeterministicSignatureClass.SPEC
360
+        rfc6979 = data.SSHTestKeyDeterministicSignatureClass.RFC_6979
361
+        try:
362
+            key_blob, rest = unstring_prefix(request_payload)
363
+            sign_data, rest = unstring_prefix(rest)
364
+        except ValueError:
365
+            return self._failure()
366
+        if len(rest) != uint32_format.size:  # flags are uint32
367
+            return self._failure()
368
+        flags = int.from_bytes(rest, "big")
369
+        if flags:
370
+            return self._failure()
371
+        if sign_data != VAULT_UUID:
372
+            return self._failure()
373
+        for key in data.ALL_KEYS.values():
374
+            if key.public_key_data == key_blob:
375
+                if spec in key.expected_signatures:
376
+                    return int.to_bytes(
377
+                        _types.SSH_AGENT.SIGN_RESPONSE, 1, "big"
378
+                    ) + string(key.expected_signatures[spec].signature)
379
+                if (
380
+                    try_rfc6979 and rfc6979 in key.expected_signatures
381
+                ):  # pragma: no cover [external]
382
+                    return int.to_bytes(
383
+                        _types.SSH_AGENT.SIGN_RESPONSE, 1, "big"
384
+                    ) + string(key.expected_signatures[rfc6979].signature)
385
+                return self._failure()
386
+        return self._failure()
387
+
388
+
389
+class StubbedSSHAgentSocketWithAddress(StubbedSSHAgentSocket):
390
+    """A [`StubbedSSHAgentSocket`][] requiring a specific address."""
391
+
392
+    ADDRESS = "stub-ssh-agent:"
393
+    """The correct address for connecting to this stubbed agent."""
394
+
395
+    def __init__(self, *extensions: str) -> None:
396
+        """Initialize the agent, based on `SSH_AUTH_SOCK`.
397
+
398
+        Socket addresses of the form `stub-ssh-agent:<errno_value>` will
399
+        raise an [`OSError`][] (or the respective subclass) with the
400
+        specified [`errno`][] value.  For example,
401
+        `stub-ssh-agent:EPERM` will raise a [`PermissionError`][].
402
+
403
+        Raises:
404
+            KeyError:
405
+                The `SSH_AUTH_SOCK` environment variable is not set.
406
+            OSError:
407
+                The address in `SSH_AUTH_SOCK` is unsuited.
408
+
409
+        """
410
+        super().__init__(*extensions)
411
+        try:
412
+            orig_address = os.environ["SSH_AUTH_SOCK"]
413
+        except KeyError as exc:
414
+            msg = "SSH_AUTH_SOCK environment variable"
415
+            raise KeyError(msg) from exc
416
+        address = orig_address
417
+        if not address.startswith(self.ADDRESS):
418
+            address = self.ADDRESS + "ENOENT"
419
+        errcode = address.removeprefix(self.ADDRESS)
420
+        if errcode and not (
421
+            errcode.startswith("E") and hasattr(errno, errcode)
422
+        ):
423
+            errcode = "EINVAL"
424
+        if errcode:
425
+            errno_val = getattr(errno, errcode)
426
+            raise OSError(errno_val, os.strerror(errno_val), orig_address)
427
+
428
+
429
+class StubbedSSHAgentSocketWithAddressAndDeterministicDSA(
430
+    StubbedSSHAgentSocketWithAddress
431
+):
432
+    """A [`StubbedSSHAgentSocketWithAddress`][] supporting deterministic DSA."""  # noqa: E501
433
+
434
+    def __init__(self) -> None:
435
+        """Initialize the agent.
436
+
437
+        Set the supported extensions, and try issuing RFC 6979 and
438
+        Pageant 0.68–0.80 DSA/ECDSA signatures, if possible.  See the
439
+        [superclass constructor][StubbedSSHAgentSocketWithAddress] for
440
+        other details.
441
+
442
+        Raises:
443
+            KeyError: See superclass.
444
+            OSError: See superclass.
445
+
446
+        """  # noqa: RUF002
447
+        super().__init__("query", "list-extended@putty.projects.tartarus.org")
448
+        self.try_rfc6979 = True
449
+        self.try_pageant_068_080 = True
450
+
451
+
452
+STUB_AGENT_ENTRY_POINT = d_sasp.SSHAgentSocketProviderEntry(
453
+    provider=StubbedSSHAgentSocket,
454
+    key="stub_agent",
455
+    aliases=(),
456
+)
457
+STUB_AGENT_WITH_ADDRESS_ENTRY_POINT = d_sasp.SSHAgentSocketProviderEntry(
458
+    provider=StubbedSSHAgentSocketWithAddress,
459
+    key="stub_agent_with_address",
460
+    aliases=(),
461
+)
462
+STUB_AGENT_WITH_ADDRESS_AND_DETERMINISTIC_DSA_ENTRY_POINT = (
463
+    d_sasp.SSHAgentSocketProviderEntry(
464
+        provider=StubbedSSHAgentSocketWithAddressAndDeterministicDSA,
465
+        key="stub_agent_with_address_and_deterministic_dsa",
466
+        aliases=(),
467
+    )
468
+)
... ...
@@ -0,0 +1,3 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
... ...
@@ -0,0 +1,819 @@
1
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Tests for the test suite's data and machinery.
6
+
7
+Currently, this entails testing [the SSH test keys][fakesshagent.ALL_KEYS]
8
+for internal consistency, and testing the functionality of the [stubbed
9
+SSH agent][fakesshagent.StubbedSSHAgentSocket], in all variations.
10
+
11
+"""
12
+
13
+from __future__ import annotations
14
+
15
+import base64
16
+import contextlib
17
+import errno
18
+import math
19
+import os
20
+import pathlib
21
+import re
22
+from typing import TYPE_CHECKING
23
+
24
+import hypothesis
25
+import pytest
26
+from hypothesis import strategies
27
+
28
+from fakesshagent import _types, data, machinery
29
+
30
+if TYPE_CHECKING:
31
+    from collections.abc import Generator
32
+
33
+    from typing_extensions import Buffer
34
+
35
+OPENSSH_MAGIC = b"openssh-key-v1\x00"
36
+OPENSSH_HEADER = (
37
+    OPENSSH_MAGIC  # magic
38
+    + b"\x00\x00\x00\x04none"  # ciphername
39
+    + b"\x00\x00\x00\x04none"  # kdfname
40
+    + b"\x00\x00\x00\x00"  # kdfoptions
41
+    + b"\x00\x00\x00\x01"  # number of keys
42
+)
43
+OPENSSH_NONE_CIPHER_BLOCKSIZE = 8
44
+
45
+
46
+def as_openssh_keyfile_payload(
47
+    public_key: bytes, private_key: bytes, checkint: int
48
+) -> bytes:
49
+    """Format an SSH private key in OpenSSH format.
50
+
51
+    Args:
52
+        public_key:
53
+            The unframed public key, in SSH wire format.
54
+        private_key:
55
+            The unframed private key, in SSH wire format, including the
56
+            comment.
57
+        checkint:
58
+            The "check" integer to use.
59
+
60
+    Returns:
61
+        The payload for a formatted OpenSSH private key, as a byte
62
+        string, without the base64 encoding and the framing lines.
63
+
64
+    """
65
+    # The OpenSSH private key file format is described in PROTOCOL.key
66
+    # in their git repository; see below for links to OpenSSH 10.0p2.
67
+    # The block size of the "none" cipher is 8 bytes; see line 108 of
68
+    # cipher.c, with definitions from line 67 onwards.  Padding is not
69
+    # used if the payload already is a multiple of 8 bytes long; see
70
+    # line 2935 onwards of sshkey.c
71
+    #
72
+    # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/PROTOCOL.key
73
+    # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/cipher.c
74
+    # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/sshkey.c
75
+    string = machinery.string
76
+    uint32 = machinery.uint32
77
+    payload = bytearray(OPENSSH_HEADER)
78
+    payload.extend(string(public_key))
79
+    secret = bytearray()
80
+    secret.extend(uint32(checkint))  # checkint
81
+    secret.extend(uint32(checkint))  # checkint
82
+    secret.extend(private_key)  # privatekey1 and comment1
83
+    i = 1
84
+    while len(secret) % OPENSSH_NONE_CIPHER_BLOCKSIZE != 0:
85
+        secret.append(i)
86
+        i += 1
87
+    payload.extend(string(secret))  # encrypted, padded list of private keys
88
+    return bytes(payload)
89
+
90
+
91
+def minimize_openssh_keyfile_padding(
92
+    decoded_openssh_private_key: bytes,
93
+) -> bytes:
94
+    """Minimize the padding used in an OpenSSH private key file.
95
+
96
+    Args:
97
+        decoded_openssh_private_key:
98
+            The non-base64-encoded, unframed, formatted OpenSSH private
99
+            key.
100
+
101
+    Returns:
102
+        The same non-base64-encoded, unframed, formatted OpenSSH private
103
+        key, but with minimal padding applied.
104
+
105
+    """
106
+    string = machinery.string
107
+    unstring_prefix = machinery.unstring_prefix
108
+
109
+    _public_key, framed_private_block = unstring_prefix(
110
+        decoded_openssh_private_key.removeprefix(OPENSSH_HEADER)
111
+    )
112
+    result = bytearray(decoded_openssh_private_key).removesuffix(
113
+        framed_private_block
114
+    )
115
+    private_block, trailer = unstring_prefix(framed_private_block)
116
+    assert not trailer
117
+
118
+    # Skip two checkint values.
119
+    key_type, remainder = unstring_prefix(private_block[8:])
120
+    # We need to semi-generically skip private key payloads.  Currently,
121
+    # all supported (test) key types exclusively store multi-precision
122
+    # integers or strings as their private key payload (which are both
123
+    # parsed the same way, but interpreted differently).  We can
124
+    # therefore generically parse `k` strings/mpints (for different
125
+    # values of `k`, depending on key type) to correctly skip the
126
+    # private key payload, and don't have to deal with having to parse
127
+    # and skip other types of data such as uint32s.
128
+    #
129
+    # (This scheme needs updating if ever a different data type needs to
130
+    # be parsed.)
131
+    num_mpints = {
132
+        b"ssh-ed25519": 2,
133
+        b"ssh-ed448": 2,
134
+        b"ssh-rsa": 6,
135
+        b"ssh-dss": 5,
136
+        b"ecdsa-sha2-nistp256": 3,
137
+        b"ecdsa-sha2-nistp384": 3,
138
+        b"ecdsa-sha2-nistp521": 3,
139
+    }
140
+    for _ in range(num_mpints[key_type]):
141
+        _, remainder = unstring_prefix(remainder)
142
+    # Skip comment.
143
+    _comment, remainder = unstring_prefix(remainder)
144
+    new_private_block = bytearray(private_block).removesuffix(remainder)
145
+    padding = bytearray(remainder)
146
+
147
+    expected_padding = bytearray()
148
+    for i in range(1, len(padding) + 1):
149
+        expected_padding.append(i & 0xFF)
150
+    assert padding == expected_padding
151
+    while len(padding) >= OPENSSH_NONE_CIPHER_BLOCKSIZE:
152
+        padding[-OPENSSH_NONE_CIPHER_BLOCKSIZE:] = b""
153
+
154
+    new_private_block.extend(padding)
155
+    result.extend(string(new_private_block))
156
+    return bytes(result)
157
+
158
+
159
+class Parametrize:
160
+    """Common test parametrizations."""
161
+
162
+    LIST_IDENTITIES = pytest.mark.parametrize(
163
+        ["extended_agent", "query_request"],
164
+        [
165
+            pytest.param(
166
+                None,
167
+                (
168
+                    # SSH string header
169
+                    b"\x00\x00\x00\x01"
170
+                    # request code: SSH_AGENTC_REQUEST_IDENTITIES
171
+                    b"\x0b"
172
+                ),
173
+                id="base",
174
+            ),
175
+            pytest.param(
176
+                True,
177
+                (
178
+                    # SSH string header
179
+                    b"\x00\x00\x00\x2e"
180
+                    # request code: SSH_AGENTC_REQUEST_IDENTITIES
181
+                    b"\x1b"
182
+                    # extension type: list-extended@putty.projects.tartarus.org
183
+                    b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org"
184
+                    # (no payload)
185
+                ),
186
+                id="extended",
187
+            ),
188
+        ],
189
+    )
190
+    QUERY_EXTENSION = pytest.mark.parametrize(
191
+        ["extended_agent", "query_response"],
192
+        [
193
+            pytest.param(
194
+                None,
195
+                (
196
+                    # SSH string header
197
+                    b"\x00\x00\x00\x01"
198
+                    # response code: SSH_AGENT_FAILURE
199
+                    b"\x05"
200
+                ),
201
+                id="base",
202
+            ),
203
+            pytest.param(
204
+                True,
205
+                (
206
+                    # SSH string header
207
+                    b"\x00\x00\x00\x40"
208
+                    # response code: SSH_AGENT_EXTENSION_RESPONSE
209
+                    b"\x1d"
210
+                    # extension response: extension type ("query")
211
+                    b"\x00\x00\x00\x05query"
212
+                    # supported extension #1: query
213
+                    b"\x00\x00\x00\x05query"
214
+                    # supported extension #2:
215
+                    # list-extended@putty.projects.tartarus.org
216
+                    b"\x00\x00\x00\x29list-extended@putty.projects.tartarus.org"
217
+                ),
218
+                id="extended",
219
+            ),
220
+        ],
221
+    )
222
+    TEST_KEYS = pytest.mark.parametrize(
223
+        ["keyname", "key"],
224
+        data.ALL_KEYS.items(),
225
+        ids=data.ALL_KEYS.keys(),
226
+    )
227
+    SUPPORTED_SSH_TEST_KEYS = pytest.mark.parametrize(
228
+        ["ssh_test_key_type", "ssh_test_key", "requires_deterministic_dsa"],
229
+        [(k, v, False) for k, v in data.SUPPORTED_KEYS.items()]
230
+        + [(k, v, True) for k, v in data.CONDITIONALLY_SUPPORTED_KEYS.items()],
231
+        ids=list(data.SUPPORTED_KEYS.keys())
232
+        + list(data.CONDITIONALLY_SUPPORTED_KEYS.keys()),
233
+    )
234
+
235
+
236
+class Strategies:
237
+    """Common hypothesis data generation strategies."""
238
+
239
+    @strategies.composite
240
+    @staticmethod
241
+    def proper_bytestring_partition(
242
+        draw: strategies.DrawFn, bs: bytes | bytearray, /
243
+    ) -> list[bytes]:
244
+        """Partition a non-empty string into non-empty parts.
245
+
246
+        A string of length `n` is partitioned [`math.isqrt(n
247
+        - 1)`][math.isqrt] many times, such that each substring is
248
+        non-empty.
249
+
250
+        """
251
+        n = len(bs)
252
+        hypothesis.assume(n > 0)
253
+        num_divisions = math.isqrt(n - 1)
254
+        divisions = draw(
255
+            strategies.lists(
256
+                strategies.integers(1, n - 1),
257
+                min_size=num_divisions,
258
+                max_size=num_divisions,
259
+                unique=True,
260
+            ).map(sorted),
261
+            label="divisions",
262
+        )
263
+        result: list[bytes] = []
264
+        i = 0
265
+        for div in divisions:
266
+            result.append(bytes(bs[i:div]))
267
+            i = div
268
+        result.append(bytes(bs[i:n]))
269
+        return result
270
+
271
+    @strategies.composite
272
+    @staticmethod
273
+    def truncated_agent_request(
274
+        draw: strategies.DrawFn, /, min_size: int = 0, max_size: int = 100
275
+    ) -> bytes:
276
+        """Generate a private-use SSH agent request with truncated payload.
277
+
278
+        The message will adhere to specified size bounds before
279
+        truncation.  The request message code will be from the private
280
+        use area.
281
+
282
+        Args:
283
+            min_size:
284
+                The minimum size of the request payload, after truncation.
285
+            max_size:
286
+                The maximum size of the request payload, before truncation.
287
+
288
+        """
289
+        request_code = draw(
290
+            strategies.integers(240, 255), label="request_code"
291
+        )
292
+        full_message_size = draw(
293
+            strategies.integers(min_size + 1, max_size),
294
+            label="full_message_size",
295
+        )
296
+        truncated_message = draw(
297
+            strategies.binary(
298
+                min_size=min_size, max_size=full_message_size - 1
299
+            )
300
+        )
301
+        payload = bytearray(
302
+            int.to_bytes(1 + full_message_size, 4, "big", signed=False)
303
+        )
304
+        payload.extend(int.to_bytes(request_code, 1, "big", signed=False))
305
+        payload.extend(truncated_message)
306
+        return bytes(payload)
307
+
308
+    @staticmethod
309
+    def invalid_ssh_agent_messages() -> strategies.SearchStrategy[bytes]:
310
+        """Generate invalid agent request messages."""
311
+        string = machinery.string
312
+        empty_message = [b""]
313
+        invalid_extension_name = [b"\x1b", b"\x00\x00\x00\x01\xff"]
314
+        sign_with_trailing_data = [
315
+            b"\x0d",
316
+            b"\x00\x00\x00\x00",
317
+            b"\x00\x00\x00\x00",
318
+            b"\x00\x00\x00\x00",
319
+            b"\x00\x00\x00\x00",
320
+        ]
321
+        sign_without_payload = [b"\x0d", b"\x00\x00\x00\x00"]
322
+        sign_with_truncated_payload = [b"\x0d", b"\x00\x00\x00\x10"]
323
+        return (
324
+            strategies
325
+            .sampled_from([
326
+                empty_message,
327
+                invalid_extension_name,
328
+                sign_with_trailing_data,
329
+                sign_without_payload,
330
+                sign_with_truncated_payload,
331
+            ])
332
+            .map(b"".join)
333
+            .map(string)
334
+        )
335
+
336
+    @staticmethod
337
+    def unsupported_ssh_agent_messages() -> strategies.SearchStrategy[bytes]:
338
+        """Generate agent request messages unsupported by the stubbed agent."""
339
+        string = machinery.string
340
+        sign_with_flags = [
341
+            b"\x0d",
342
+            string(data.ALL_KEYS["rsa"].public_key_data),
343
+            string(machinery.VAULT_UUID),
344
+            b"\x00\x00\x00\x02",
345
+        ]
346
+        sign_with_nonstandard_passphrase = [
347
+            b"\x0d",
348
+            string(data.ALL_KEYS["ed25519"].public_key_data),
349
+            b"\x00\x00\x00\x08\x00\x01\x02\x03\x04\x05\x06\x07",
350
+            b"\x00\x00\x00\x00",
351
+        ]
352
+        # NOTE: only unsupported when stubbed agent has RFC 6979 support
353
+        # disabled
354
+        sign_key_no_expected_signature = [
355
+            b"\x0d",
356
+            string(data.ALL_KEYS["dsa1024"].public_key_data),
357
+            string(machinery.VAULT_UUID),
358
+            b"\x00\x00\x00\x00",
359
+        ]
360
+        sign_key_unregistered_test_key = [
361
+            b"\x0d",
362
+            b"\x00\x00\x00\x00",
363
+            string(machinery.VAULT_UUID),
364
+            b"\x00\x00\x00\x00",
365
+        ]
366
+        return (
367
+            strategies
368
+            .sampled_from([
369
+                sign_with_flags,
370
+                sign_with_nonstandard_passphrase,
371
+                sign_key_no_expected_signature,
372
+                sign_key_unregistered_test_key,
373
+            ])
374
+            .map(b"".join)
375
+            .map(string)
376
+        )
377
+
378
+    @staticmethod
379
+    def stubbed_agent_addresses() -> strategies.SearchStrategy[
380
+        tuple[str, type[OSError], str]
381
+    ]:
382
+        """Generate agent addresses for the stubbed agents.
383
+
384
+        These are all error calls.  Non-error calls are supplied by the
385
+        explicit examples.
386
+
387
+        """
388
+        invalid_url = (
389
+            str(pathlib.Path("~").expanduser()),
390
+            FileNotFoundError,
391
+            os.strerror(errno.ENOENT),
392
+        )
393
+        protocol_not_supported = (
394
+            "stub-ssh-agent:EPROTONOSUPPORT",
395
+            OSError,
396
+            os.strerror(errno.EPROTONOSUPPORT),
397
+        )
398
+        invalid_error_code = (
399
+            "stub-ssh-agent:ABCDEFGHIJKLMNOPQRSTUVWXYZ",
400
+            OSError,
401
+            os.strerror(errno.EINVAL),
402
+        )
403
+        return strategies.sampled_from([
404
+            invalid_url,
405
+            protocol_not_supported,
406
+            invalid_error_code,
407
+        ])
408
+
409
+
410
+class TestTestKeys:
411
+    """Tests testing the test keys."""
412
+
413
+    @Parametrize.TEST_KEYS
414
+    def test_public_keys_are_internally_consistent(
415
+        self,
416
+        keyname: str,
417
+        key: data.SSHTestKey,
418
+    ) -> None:
419
+        """The public key data structures are internally consistent."""
420
+        del keyname
421
+        string = machinery.string
422
+        public_key_lines = key.public_key.splitlines(keepends=False)
423
+        assert len(public_key_lines) == 1
424
+        line_parts = public_key_lines[0].strip(b"\r\n").split(None, 2)
425
+        key_type_name, public_key_b64 = line_parts[:2]
426
+        assert base64.standard_b64encode(key.public_key_data) == public_key_b64
427
+        assert key.public_key_data.startswith(string(key_type_name))
428
+
429
+    # TODO(the-13th-letter): Put RSA key mangling into helper method.
430
+    @Parametrize.TEST_KEYS
431
+    def test_private_keys_are_consistent_with_public_keys(
432
+        self,
433
+        keyname: str,
434
+        key: data.SSHTestKey,
435
+    ) -> None:
436
+        """The private key data are consistent with their public parts."""
437
+        del keyname
438
+        string = machinery.string
439
+
440
+        if key.public_key_data.startswith(string(b"ssh-rsa")):
441
+            # RSA public keys are *not* prefixes of the corresponding
442
+            # private key in OpenSSH format! RSA public keys consist of
443
+            # an exponent e and a modulus n, which in the public key are
444
+            # in the order (e, n), but in the order (n, e) in the
445
+            # OpenSSH private key.  We thus need to parse and rearrange
446
+            # the components of the public key into a new "mangled"
447
+            # public key that then *is* a prefix of the respective
448
+            # private key.
449
+            unstring_prefix = machinery.unstring_prefix
450
+            key_type, numbers = unstring_prefix(key.public_key_data)
451
+            e, encoded_n = unstring_prefix(numbers)
452
+            n, trailer = unstring_prefix(encoded_n)
453
+            assert not trailer
454
+            mangled_public_key_data = string(key_type) + string(n) + string(e)
455
+            assert (
456
+                key.private_key_blob[: len(mangled_public_key_data)]
457
+                == mangled_public_key_data
458
+            )
459
+        else:
460
+            assert (
461
+                key.private_key_blob[: len(key.public_key_data)]
462
+                == key.public_key_data
463
+            )
464
+
465
+    @Parametrize.TEST_KEYS
466
+    def test_private_keys_are_internally_consistent(
467
+        self,
468
+        keyname: str,
469
+        key: data.SSHTestKey,
470
+    ) -> None:
471
+        """The private key data structures are internally consistent."""
472
+        del keyname
473
+        string = machinery.string
474
+
475
+        private_key_lines = [
476
+            line
477
+            for line in key.private_key.splitlines(keepends=False)
478
+            if line and not line.startswith((b"-----BEGIN", b"-----END"))
479
+        ]
480
+        private_key_from_openssh = base64.standard_b64decode(
481
+            b"".join(private_key_lines)
482
+        )
483
+        wrapped_public_key = string(key.public_key_data)
484
+        assert (
485
+            private_key_from_openssh[
486
+                len(OPENSSH_HEADER) : len(OPENSSH_HEADER)
487
+                + len(wrapped_public_key)
488
+            ]
489
+            == wrapped_public_key
490
+        )
491
+
492
+        # Offset skips the header, the wrapped public key, and the
493
+        # framing of the private keys section.
494
+        offset = len(OPENSSH_HEADER) + len(wrapped_public_key) + 4
495
+        checkint = int.from_bytes(
496
+            private_key_from_openssh[offset : offset + 4], "big"
497
+        )
498
+        assert minimize_openssh_keyfile_padding(
499
+            private_key_from_openssh
500
+        ) == minimize_openssh_keyfile_padding(
501
+            as_openssh_keyfile_payload(
502
+                public_key=key.public_key_data,
503
+                private_key=key.private_key_blob,
504
+                checkint=checkint,
505
+            )
506
+        )
507
+
508
+
509
+class TestStubbedSSHAgentSocket:
510
+    """Test the stubbed SSH agent socket: common machinery."""
511
+
512
+    @contextlib.contextmanager
513
+    def _get_agent(
514
+        self, *, extended_agent: bool | None = False
515
+    ) -> Generator[machinery.StubbedSSHAgentSocket, None, None]:
516
+        agent_class: type[machinery.StubbedSSHAgentSocket] = (
517
+            machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA
518
+            if extended_agent
519
+            else machinery.StubbedSSHAgentSocketWithAddress
520
+            if extended_agent is not None
521
+            else machinery.StubbedSSHAgentSocket
522
+        )
523
+        with contextlib.ExitStack() as stack:
524
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
525
+            if issubclass(
526
+                agent_class, machinery.StubbedSSHAgentSocketWithAddress
527
+            ):
528
+                monkeypatch.setenv("SSH_AUTH_SOCK", agent_class.ADDRESS)
529
+            else:
530
+                monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
531
+            yield stack.enter_context(agent_class())
532
+
533
+
534
+class TestStubbedSSHAgentSocketRequests(TestStubbedSSHAgentSocket):
535
+    """Test the stubbed SSH agent socket: normal requests."""
536
+
537
+    @Parametrize.QUERY_EXTENSION
538
+    def test_query_extensions(
539
+        self, extended_agent: bool, query_response: bytes
540
+    ) -> None:
541
+        """The agent implements a known list of extensions.
542
+
543
+        The list is empty for the base agent, and non-empty for the
544
+        extended agent.
545
+
546
+        """
547
+        query_request = (
548
+            # SSH string header
549
+            b"\x00\x00\x00\x0a"
550
+            # request code: SSH_AGENTC_EXTENSION
551
+            b"\x1b"
552
+            # payload: SSH string "query"
553
+            b"\x00\x00\x00\x05query"
554
+        )
555
+        with self._get_agent(extended_agent=extended_agent) as agent:
556
+            assert ("query" in agent.enabled_extensions) == bool(
557
+                extended_agent
558
+            )
559
+            agent.sendall(query_request)
560
+            assert agent.recv(1000) == query_response
561
+
562
+    @Parametrize.LIST_IDENTITIES
563
+    def test_request_identities(
564
+        self, extended_agent: bool | None, query_request: bytes
565
+    ) -> None:
566
+        """The agent implements a known list of identities.
567
+
568
+        The extended agent implements PuTTY's `list-extended` extension.
569
+
570
+        """
571
+        unstring_prefix = machinery.unstring_prefix
572
+        with self._get_agent(extended_agent=extended_agent) as agent:
573
+            agent.sendall(query_request)
574
+            message_length = int.from_bytes(agent.recv(4), "big")
575
+            orig_message: bytes | bytearray = bytearray(
576
+                agent.recv(message_length)
577
+            )
578
+            assert (
579
+                orig_message[0] == _types.SSH_AGENT.SUCCESS
580
+                if extended_agent
581
+                else _types.SSH_AGENT.IDENTITIES_ANSWER
582
+            )
583
+            identity_count = int.from_bytes(orig_message[1:5], "big")
584
+            message = bytes(orig_message[5:])
585
+            for _ in range(identity_count):
586
+                key, message = unstring_prefix(message)
587
+                _comment, message = unstring_prefix(message)
588
+                flags, message = (
589
+                    unstring_prefix(message)
590
+                    if extended_agent
591
+                    else (b"", message)
592
+                )
593
+                assert not extended_agent or flags == b"\x00\x00\x00\x00"
594
+                assert key
595
+                assert key in {
596
+                    k.public_key_data for k in data.ALL_KEYS.values()
597
+                }
598
+            assert not message
599
+
600
+    @Parametrize.SUPPORTED_SSH_TEST_KEYS
601
+    def test_sign(
602
+        self,
603
+        ssh_test_key_type: str,
604
+        ssh_test_key: data.SSHTestKey,
605
+        requires_deterministic_dsa: bool,
606
+    ) -> None:
607
+        """The agent signs known key/message pairs."""
608
+        del ssh_test_key_type
609
+        signature_types = (
610
+            [
611
+                data.SSHTestKeyDeterministicSignatureClass.RFC_6979,
612
+                data.SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
613
+            ]
614
+            if requires_deterministic_dsa
615
+            else [data.SSHTestKeyDeterministicSignatureClass.SPEC]
616
+        )
617
+        signature_names = [t.name for t in signature_types]
618
+        expected_signatures = {
619
+            ssh_test_key.expected_signatures[t].signature
620
+            for t in signature_types
621
+            if ssh_test_key.expected_signatures[t].signature is not None
622
+        }
623
+        assert expected_signatures, (
624
+            f"expected a known {'/'.join(signature_names)} deterministic signature"
625
+        )
626
+        string = machinery.string
627
+        query_request = string(
628
+            # request code: SSH_AGENTC_SIGN_REQUEST
629
+            b"\x0d"
630
+            # key: SSH string of the public key
631
+            + string(ssh_test_key.public_key_data)
632
+            # payload: SSH string of the vault UUID
633
+            + string(machinery.VAULT_UUID)
634
+            # signing flags (uint32, empty)
635
+            + b"\x00\x00\x00\x00"
636
+        )
637
+        query_responses = {
638
+            string(
639
+                # response code: SSH_AGENT_SIGN_RESPONSE
640
+                b"\x0e"
641
+                # expected payload: the binary signature as recorded in the test key data structure
642
+                + string(sig)
643
+            )
644
+            for sig in expected_signatures
645
+        }
646
+        with pytest.MonkeyPatch.context() as monkeypatch:
647
+            monkeypatch.setenv(
648
+                "SSH_AUTH_SOCK",
649
+                machinery.StubbedSSHAgentSocketWithAddress.ADDRESS,
650
+            )
651
+            agent: machinery.StubbedSSHAgentSocket = (
652
+                machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA()
653
+                if requires_deterministic_dsa
654
+                else machinery.StubbedSSHAgentSocket()
655
+            )
656
+            with agent:
657
+                agent.sendall(query_request)
658
+                assert agent.recv(1000) in query_responses
659
+
660
+
661
+class TestStubbedSSHAgentSocketProperOperations(TestStubbedSSHAgentSocket):
662
+    """Test the stubbed SSH agent socket: proper use and misuse."""
663
+
664
+    def test_close_multiple(
665
+        self,
666
+    ) -> None:
667
+        """The agent can be closed repeatedly."""
668
+        with self._get_agent(extended_agent=None) as agent:
669
+            pass
670
+        with agent:
671
+            pass
672
+        del agent
673
+
674
+    def test_closed_agents_cannot_be_interacted_with(
675
+        self,
676
+    ) -> None:
677
+        """The agent cannot be usefully used after close."""
678
+        with self._get_agent(extended_agent=None) as agent:
679
+            pass
680
+        query_request = (
681
+            # SSH string header
682
+            b"\x00\x00\x00\x0a"
683
+            # request code: SSH_AGENTC_EXTENSION
684
+            b"\x1b"
685
+            # payload: SSH string "query"
686
+            b"\x00\x00\x00\x05query"
687
+        )
688
+        with pytest.raises(OSError, match=re.escape(os.strerror(errno.EBADF))):
689
+            agent.sendall(query_request)
690
+        with pytest.raises(OSError, match=re.escape(os.strerror(errno.EBADF))):
691
+            agent.recv(100)
692
+
693
+    def test_no_recv_without_sendall(
694
+        self,
695
+    ) -> None:
696
+        """The agent requires a message before sending a response."""
697
+        with self._get_agent(extended_agent=None) as agent:  # noqa: SIM117
698
+            with pytest.raises(
699
+                AssertionError,
700
+                match=re.escape(
701
+                    machinery.StubbedSSHAgentSocket._PROTOCOL_VIOLATION
702
+                ),
703
+            ):
704
+                agent.recv(100)
705
+
706
+    @hypothesis.given(
707
+        query_request_parts=Strategies.proper_bytestring_partition(
708
+            b"\x00\x00\x00\x0a\x1b\x00\x00\x00\x05query"
709
+        )
710
+    )
711
+    @hypothesis.example(
712
+        query_request_parts=[
713
+            b"",
714
+            b"\x00\x00\x00",
715
+            b"\x0a",
716
+            b"\x1b",
717
+            b"\x00\x00\x00\x05",
718
+            b"query",
719
+        ]
720
+    )
721
+    def test_piecemeal_sendall(
722
+        self,
723
+        query_request_parts: list[bytes],
724
+    ) -> None:
725
+        """The agent supports receiving messages incrementally."""
726
+        with self._get_agent(extended_agent=None) as agent:
727
+            agent.enabled_extensions = frozenset({"query"})
728
+            for part in query_request_parts:
729
+                agent.sendall(part)
730
+            header = agent.recv(agent.HEADER_SIZE)
731
+            count = int.from_bytes(header, "big", signed=False)
732
+            payload = agent.recv(count)
733
+            assert len(payload) == count
734
+            assert bytes.startswith(
735
+                payload,
736
+                (
737
+                    bytes(_types.SSH_AGENT.SUCCESS),
738
+                    bytes(_types.SSH_AGENT.EXTENSION_RESPONSE),
739
+                ),
740
+            )
741
+            code = payload[: agent.CODE_SIZE]
742
+            assert code == bytes(_types.SSH_AGENT.SUCCESS) or code == bytes(
743
+                _types.SSH_AGENT.EXTENSION_RESPONSE
744
+            )
745
+
746
+    @hypothesis.given(message=Strategies.invalid_ssh_agent_messages())
747
+    def test_invalid_ssh_agent_messages(
748
+        self,
749
+        message: Buffer,
750
+    ) -> None:
751
+        """The agent responds with errors on invalid messages."""
752
+        query_response = (
753
+            # SSH string header
754
+            b"\x00\x00\x00\x01"
755
+            # response code: SSH_AGENT_FAILURE
756
+            b"\x05"
757
+        )
758
+        with machinery.StubbedSSHAgentSocket() as agent:
759
+            agent.sendall(message)
760
+            assert agent.recv(100) == query_response
761
+
762
+    @hypothesis.given(message=Strategies.truncated_agent_request())
763
+    @hypothesis.example(message=b"\x00\x00\x00\x0f\xff")
764
+    def test_truncated_ssh_agent_message(
765
+        self,
766
+        message: bytes,
767
+    ) -> None:
768
+        """The agent diagnoses a final truncated request message."""
769
+        with pytest.raises(  # noqa: SIM117
770
+            AssertionError,
771
+            match=re.escape(
772
+                machinery.StubbedSSHAgentSocket._INCOMPLETE_REQUEST
773
+            ),
774
+        ):
775
+            with self._get_agent(extended_agent=None) as agent:
776
+                agent.sendall(message)
777
+
778
+
779
+class TestStubbedSSHAgentSocketSupportedAndUnsupportedFeatures(
780
+    TestStubbedSSHAgentSocket
781
+):
782
+    """Test the stubbed SSH agent socket: supported/unsupported features."""
783
+
784
+    @hypothesis.given(message=Strategies.unsupported_ssh_agent_messages())
785
+    def test_unsupported_ssh_agent_messages(
786
+        self,
787
+        message: Buffer,
788
+    ) -> None:
789
+        """The agent responds with errors on unsupported messages."""
790
+        query_response = (
791
+            # SSH string header
792
+            b"\x00\x00\x00\x01"
793
+            # response code: SSH_AGENT_FAILURE
794
+            b"\x05"
795
+        )
796
+        with self._get_agent(extended_agent=None) as agent:
797
+            agent.sendall(message)
798
+            assert agent.recv(100) == query_response
799
+
800
+    @hypothesis.given(args_tuple=Strategies.stubbed_agent_addresses())
801
+    @hypothesis.example(args_tuple=(None, KeyError, "SSH_AUTH_SOCK"))
802
+    @hypothesis.example(args_tuple=("stub-ssh-agent:", None, ""))
803
+    def test_addresses(
804
+        self,
805
+        args_tuple: tuple[str | None, type[Exception] | None, str],
806
+    ) -> None:
807
+        """The agent accepts addresses."""
808
+        address, exception, match = args_tuple
809
+        with contextlib.ExitStack() as stack:
810
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
811
+            if address:
812
+                monkeypatch.setenv("SSH_AUTH_SOCK", address)
813
+            else:
814
+                monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
815
+            if exception:
816
+                stack.enter_context(
817
+                    pytest.raises(exception, match=re.escape(match))
818
+                )
819
+            machinery.StubbedSSHAgentSocketWithAddress()
0 820