Repository: pyca/cryptography

Issue:
cryptography.exceptions.UnsupportedAlgorithm: Unsupported key type: b'sk-ssh-ed25519@openssh.com' - YubiKey SSH public keys
Hi,

using:

  * Python 3.12.2
  * cryptography 42.0.5
  * cffi         1.16.0

parsing the public key of a yubikey U2F ssh public key fails

```python
import cryptography.hazmat.primitives.serialization

cryptography.hazmat.primitives.serialization.load_ssh_public_key(b"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIMwRvXyiJg+fzGNsKeTm7gArZwzrDtnXTZvp9GcaClTaAAAADHNzaDpZdWJpa2V5Mw==")
```

```
cryptography.exceptions.UnsupportedAlgorithm: Unsupported key type: b'sk-ssh-ed25519@openssh.com'
```

Due to the unknown key format 
https://github.com/pyca/cryptography/blob/090bdf06016737e6df713f432c8b0c9fe5f871c1/src/cryptography/hazmat/primitives/serialization/ssh.py#L575-L582

and -later on- additional/unparsed application strings at the end of the container:

when force feeding a new format …
```python
cryptography.hazmat.primitives.serialization.ssh._KEY_FORMATS[b"sk-ssh-ed25519@openssh.com"] = cryptography.hazmat.primitives.serialization.ssh._KEY_FORMATS[b"ssh-ed25519"]
```

```python
cryptography.hazmat.primitives.serialization.load_ssh_public_key(b"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIMwRvXyiJg+fzGNsKeTm7gArZwzrDtnXTZvp9GcaClTaAAAADHNzaDpZdWJpa2V5Mw==")
```
```
ssh.py", line 1033, in _load_ssh_public_identity
    _check_empty(rest)
  File "~/venv/lib/python3.10/site-packages/cryptography/hazmat/primitives/serialization/ssh.py", line 184, in _check_empty
    raise ValueError("Corrupt data: unparsed data")
```

https://github.com/openssh/openssh-portable/blob/e27f032aa8fcbae9b2e7c451baaf4b8ac6fa3d45/PROTOCOL.u2f#L81-L86
```
The format of a sk-ssh-ed25519@openssh.com public key is:

	string		"sk-ssh-ed25519@openssh.com"
	string		public key
	string		application (user-specified, but typically "ssh:")
```




Discussion:
We should add support, though one caveat is that the public key will not contain the application value. So if you need that, simply adding support to `load_ssh_public_key` may be insufficient.
Would you be interested in contributing a PR adding support?

Resolving patch:
diff --git a/src/cryptography/hazmat/primitives/serialization/ssh.py b/src/cryptography/hazmat/primitives/serialization/ssh.py
--- a/src/cryptography/hazmat/primitives/serialization/ssh.py
+++ b/src/cryptography/hazmat/primitives/serialization/ssh.py
@@ -64,6 +64,10 @@ def _bcrypt_kdf(
 _ECDSA_NISTP521 = b"ecdsa-sha2-nistp521"
 _CERT_SUFFIX = b"-cert-v01@openssh.com"
 
+# U2F application string suffixed pubkey
+_SK_SSH_ED25519 = b"sk-ssh-ed25519@openssh.com"
+_SK_SSH_ECDSA_NISTP256 = b"sk-ecdsa-sha2-nistp256@openssh.com"
+
 # These are not key types, only algorithms, so they cannot appear
 # as a public key type
 _SSH_RSA_SHA256 = b"rsa-sha2-256"
@@ -572,6 +576,56 @@ def encode_private(
         f_priv.put_sshstr(f_keypair)
 
 
+def load_application(data) -> tuple[memoryview, memoryview]:
+    """
+    U2F application strings
+    """
+    application, data = _get_sshstr(data)
+    if not application.tobytes().startswith(b"ssh:"):
+        raise ValueError(
+            "U2F application string does not start with b'ssh:' "
+            f"({application})"
+        )
+    return application, data
+
+
+class _SSHFormatSKEd25519:
+    """
+    The format of a sk-ssh-ed25519@openssh.com public key is:
+
+        string		"sk-ssh-ed25519@openssh.com"
+        string		public key
+        string		application (user-specified, but typically "ssh:")
+    """
+
+    def load_public(
+        self, data: memoryview
+    ) -> tuple[ed25519.Ed25519PublicKey, memoryview]:
+        """Make Ed25519 public key from data."""
+        public_key, data = _lookup_kformat(_SSH_ED25519).load_public(data)
+        application, data = load_application(data)
+        return public_key, data
+
+
+class _SSHFormatSKECDSA:
+    """
+    The format of a sk-ecdsa-sha2-nistp256@openssh.com public key is:
+
+        string		"sk-ecdsa-sha2-nistp256@openssh.com"
+        string		curve name
+        ec_point	Q
+        string		application (user-specified, but typically "ssh:")
+    """
+
+    def load_public(
+        self, data: memoryview
+    ) -> tuple[ec.EllipticCurvePublicKey, memoryview]:
+        """Make Ed25519 public key from data."""
+        public_key, data = _lookup_kformat(_ECDSA_NISTP256).load_public(data)
+        application, data = load_application(data)
+        return public_key, data
+
+
 _KEY_FORMATS = {
     _SSH_RSA: _SSHFormatRSA(),
     _SSH_DSA: _SSHFormatDSA(),
@@ -579,6 +633,8 @@ def encode_private(
     _ECDSA_NISTP256: _SSHFormatECDSA(b"nistp256", ec.SECP256R1()),
     _ECDSA_NISTP384: _SSHFormatECDSA(b"nistp384", ec.SECP384R1()),
     _ECDSA_NISTP521: _SSHFormatECDSA(b"nistp521", ec.SECP521R1()),
+    _SK_SSH_ED25519: _SSHFormatSKEd25519(),
+    _SK_SSH_ECDSA_NISTP256: _SSHFormatSKECDSA(),
 }
 
 


Test patch:
diff --git a/tests/hazmat/primitives/test_ssh.py b/tests/hazmat/primitives/test_ssh.py
--- a/tests/hazmat/primitives/test_ssh.py
+++ b/tests/hazmat/primitives/test_ssh.py
@@ -55,6 +55,10 @@ class TestOpenSSHSerialization:
             ("ecdsa-nopsw.key.pub", "ecdsa-nopsw.key-cert.pub"),
             ("ed25519-psw.key.pub", None),
             ("ed25519-nopsw.key.pub", "ed25519-nopsw.key-cert.pub"),
+            ("sk-ecdsa-psw.key.pub", None),
+            ("sk-ecdsa-nopsw.key.pub", None),
+            ("sk-ed25519-psw.key.pub", None),
+            ("sk-ed25519-nopsw.key.pub", None),
         ],
     )
     def test_load_ssh_public_key(self, key_file, cert_file, backend):
@@ -80,10 +84,14 @@ def test_load_ssh_public_key(self, key_file, cert_file, backend):
                 )
         else:
             public_key = load_ssh_public_key(pub_data, backend)
-            assert (
-                public_key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH)
-                == nocomment_data
-            )
+            if not key_file.startswith("sk-"):
+                # SK keys do not round-trip
+                assert (
+                    public_key.public_bytes(
+                        Encoding.OpenSSH, PublicFormat.OpenSSH
+                    )
+                    == nocomment_data
+                )
 
         self.run_partial_pubkey(pub_data, backend)
 
@@ -1800,3 +1808,20 @@ def test_sign_and_byte_compare_ed25519(self, monkeypatch, backend):
             b"t8yRa8IRbxvOyA9TZYDGG1dRE3DiR0fuudU20v6vqfTd1gx0S5QyEdECXLl9ZI3"
             b"AwZgc="
         )
+
+
+class TestSSHSK:
+    @staticmethod
+    def ssh_str(application):
+        data = (
+            len(application).to_bytes(length=4, byteorder="big")
+            + application.encode()
+        )
+        return memoryview(data)
+
+    def test_load_application(self):
+        ssh.load_application(self.ssh_str("ssh:test"))
+
+    def test_load_application_valueerror(self):
+        with pytest.raises(ValueError):
+            ssh.load_application(self.ssh_str("hss:test"))