~jan0sch/smederee

Showing details for patch 4c636082a6d4f68abc4c9e0bb5ca2f64f266d750.
2022-10-18 (Tue), 6:20 PM - Jens Grassel - 4c636082a6d4f68abc4c9e0bb5ca2f64f266d750

SSH: More work on PublicSshKey struct

- add bouncy castle as full dependency
- refactor PublicSshKey for more proper typing
- add helpers
- add tests
Summary of changes
3 files modified with 212 lines added and 8 lines removed
  • build.sbt with 1 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala with 155 added and 7 removed lines
  • modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala with 56 added and 0 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-02-02 00:37:11.462166112 +0000
+++ new-smederee/build.sbt	2025-02-02 00:37:11.462166112 +0000
@@ -162,7 +162,7 @@
         library.apacheSshdCore,
         library.apacheSshdSftp,
         library.apacheSshdScp,
-        library.bouncyCastleProvider % Runtime,
+        library.bouncyCastleProvider,
         library.catsCore,
         library.circeCore,
         library.circeGeneric,
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala	2025-02-02 00:37:11.462166112 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala	2025-02-02 00:37:11.462166112 +0000
@@ -17,15 +17,126 @@
 
 package de.smederee.ssh
 
-import java.security.PublicKey
-import java.util.UUID
+import java.security.MessageDigest
+import java.util.{ Base64, UUID }
 
 import cats._
 import cats.syntax.all._
 import de.smederee.hub._
+import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil
 
+import scala.util.Try
 import scala.util.matching.Regex
 
+opaque type EncodedKeyBytes = String
+object EncodedKeyBytes {
+  val Format: Regex = "^[a-zA-z0-9+/]+=*$".r
+
+  /** Create an instance of EncodedKeyBytes from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a EncodedKeyBytes.
+    * @return
+    *   The appropriate instance of EncodedKeyBytes.
+    */
+  def apply(source: String): EncodedKeyBytes = source
+
+  /** Try to create an instance of EncodedKeyBytes from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a EncodedKeyBytes.
+    * @return
+    *   An option to the successfully converted EncodedKeyBytes.
+    */
+  def from(source: String): Option[EncodedKeyBytes] = Option(source).filter(string => Format.matches(string))
+
+}
+
+extension (keyBytes: EncodedKeyBytes) {
+
+  /** Convert the key bytes into an array of bytes by decoding the base64 string.
+    *
+    * @return
+    *   The decoded bytes from the actual key bytes that were base64 encoded.
+    */
+  def toByteArray: Array[Byte] = Base64.getDecoder().decode(keyBytes)
+}
+
+opaque type KeyFingerprint = String
+object KeyFingerprint {
+
+  /** Create an instance of KeyFingerprint from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a KeyFingerprint.
+    * @return
+    *   The appropriate instance of KeyFingerprint.
+    */
+  def apply(source: String): KeyFingerprint = source
+
+  /** Try to create an instance of KeyFingerprint from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a KeyFingerprint.
+    * @return
+    *   An option to the successfully converted KeyFingerprint.
+    */
+  def from(source: String): Option[KeyFingerprint] = Option(source).filter(_.nonEmpty)
+
+}
+
+opaque type PublicSshKeyId = UUID
+object PublicSshKeyId {
+  val Format = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$".r
+
+  given Eq[PublicSshKeyId] = Eq.fromUniversalEquals
+
+  /** Create an instance of PublicSshKeyId from the given UUID type.
+    *
+    * @param source
+    *   An instance of type UUID which will be returned as a PublicSshKeyId.
+    * @return
+    *   The appropriate instance of PublicSshKeyId.
+    */
+  def apply(source: UUID): PublicSshKeyId = source
+
+  /** Try to create an instance of PublicSshKeyId from the given UUID.
+    *
+    * @param source
+    *   A UUID that should fulfil the requirements to be converted into a PublicSshKeyId.
+    * @return
+    *   An option to the successfully converted PublicSshKeyId.
+    */
+  def from(source: UUID): Option[PublicSshKeyId] = Option(source)
+
+  /** Try to create an instance of PublicSshKeyId from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a PublicSshKeyId.
+    * @return
+    *   An option to the successfully converted PublicSshKeyId.
+    */
+  def fromString(source: String): Either[String, PublicSshKeyId] =
+    Option(source)
+      .filter(s => Format.matches(s))
+      .flatMap { uuidString =>
+        Either.catchNonFatal(UUID.fromString(uuidString)).toOption
+      }
+      .toRight("Illegal value for PublicSshKeyId!")
+
+  /** Generate a new random key id.
+    *
+    * @return
+    *   A key id which is pseudo randomly generated.
+    */
+  def randomId: PublicSshKeyId = UUID.randomUUID
+
+}
+
+extension (keyId: PublicSshKeyId) {
+  def toUUID: UUID = keyId
+}
+
 opaque type SshPublicKeyString = String
 object SshPublicKeyString {
   val Format: Regex = "^([\\w-]+)\\s(.+)(\\s(.+))?$".r
@@ -105,7 +216,7 @@
   *   The unique id of the user account associated with this key.
   * @param keyType
   *   The type of the key (rsa, ed25519, ...).
-  * @param key
+  * @param keyBytes
   *   The actual key (base 64 encoded).
   * @param fingerprint
   *   The fingerprint of the key.
@@ -113,10 +224,47 @@
   *   An optional comment for the key, quite often this is an email address.
   */
 final case class PublicSshKey(
-    id: UUID,
+    id: PublicSshKeyId,
     ownerId: UserId,
     keyType: SshKeyType,
-    key: String,
-    fingerprint: String,
+    keyBytes: EncodedKeyBytes,
+    fingerprint: KeyFingerprint,
     comment: Option[String]
 )
+
+object PublicSshKey {
+  private val digest = MessageDigest.getInstance("SHA256")
+
+  /** Create a [[PublicSshKey]] from the given parameters and [[SshPublicKeyString]].
+    *
+    * @param id
+    *   The globally unique id of the ssh key.
+    * @param ownerId
+    *   The unique id of the user account associated with this key.
+    * @param sshKey
+    *   A string containing a valid OpenSSH public key.
+    * @return
+    *   An option to the sucessfully created [[PublicSshKey]].
+    */
+  def from(id: PublicSshKeyId)(ownerId: UserId)(sshKey: SshPublicKeyString): Option[PublicSshKey] = {
+    val keyType = SshKeyType.from(sshKey.toString)
+    val base64Key = EncodedKeyBytes.from(
+      sshKey.toString.dropWhile(char => !char.isWhitespace).trim.takeWhile(char => !char.isWhitespace)
+    )
+    val comment = sshKey.toString.split("\\s").drop(2).toList match {
+      case Nil          => None
+      case commentParts => Option(commentParts.mkString(" "))
+    }
+    val fingerprint = base64Key.flatMap { base64Key =>
+      val rawFingerprint = Try {
+        val publicKey   = OpenSSHPublicKeyUtil.parsePublicKey(base64Key.toByteArray)
+        val digestedKey = digest.digest(OpenSSHPublicKeyUtil.encodePublicKey(publicKey))
+        Base64.getEncoder().encodeToString(digestedKey).reverse.dropWhile(_ == '=').reverse // Remove padding (`=`)
+      }.toOption
+      rawFingerprint.flatMap(KeyFingerprint.from)
+    }
+    (keyType, base64Key, fingerprint).mapN { case (keyType, base64Key, fingerprint) =>
+      PublicSshKey(id, ownerId, keyType, base64Key, fingerprint, comment)
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala	2025-02-02 00:37:11.462166112 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala	2025-02-02 00:37:11.462166112 +0000
@@ -17,6 +17,8 @@
 
 package de.smederee.ssh
 
+import de.smederee.hub._
+
 import munit._
 
 final class PublicSshKeyTest extends FunSuite {
@@ -49,4 +51,58 @@
       case Some(keyString) => assertEquals(keyString, expected)
     }
   }
+
+  test("PublicSshKey.from must work for keys with comment") {
+    val input = scala.io.Source
+      .fromInputStream(
+        getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-with-comment.pub"),
+        "UTF-8"
+      )
+      .getLines()
+      .mkString
+    val sshKey  = SshPublicKeyString(input)
+    val id      = PublicSshKeyId.randomId
+    val ownerId = UserId.randomUserId
+    PublicSshKey.from(id)(ownerId)(sshKey) match {
+      case Some(key) =>
+        assertEquals(key.comment, Option("Some optional comment..."))
+        assertEquals(key.fingerprint, KeyFingerprint("qduYGwQlx7kMHo7GBNwx6tULMTxbuEbDJ6pdgC88ZSo"))
+        assertEquals(key.id, id)
+        assertEquals(
+          key.keyBytes,
+          EncodedKeyBytes("AAAAC3NzaC1lZDI1NTE5AAAAIH8ZU2xquZvstbesPktthwY2r5sanULBQKuM5bGHVdeP")
+        )
+        assertEquals(key.keyType, SshKeyType.SshEd25519)
+        assertEquals(key.ownerId, ownerId)
+      case _ => fail("PublicSshKey could not be created!")
+    }
+  }
+
+  test("PublicSshKey.from must work for keys without comment") {
+    val input = scala.io.Source
+      .fromInputStream(
+        getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-without-comment.pub"),
+        "UTF-8"
+      )
+      .getLines()
+      .mkString
+    val sshKey  = SshPublicKeyString(input)
+    val id      = PublicSshKeyId.randomId
+    val ownerId = UserId.randomUserId
+    PublicSshKey.from(id)(ownerId)(sshKey) match {
+      case Some(key) =>
+        assertEquals(key.comment, None)
+        assertEquals(key.fingerprint, KeyFingerprint("tPwxtBT8SN5nq1kbT/vERM/pkJUAtMP6j+sf3m75UqE"))
+        assertEquals(key.id, id)
+        assertEquals(
+          key.keyBytes,
+          EncodedKeyBytes(
+            "AAAAB3NzaC1kc3MAAACBAKn1DHh6DaIg/cN6vNVh1VXvHhH86eKelfsolIfvTPQSb3vkqoWPG3T3DGmrUjbqvrrfzaKILTBRv05KqMCbJKETGR0fuY7G1/Nkd/6dZjw1ngYkGd0fr2ERGuq87+gdd1A3TeIqvdjnl7MG3bEGf+fIEJOrRJraZ+u/tDFlSYq/AAAAFQCAUrv94uu1dVTTiyoagKV4Y4QWuQAAAIAuR5mFFYAgT1+t1u16eRCou1nPO4+q35/6uNNCyXtP0BmZaxXqQw25foJz5OzSQWXjjianfRfUyjsHt5DgM0PAIZaqmxMUiVw7BT7zUTa7ucl9NQmFBexiedCtokVb8++vHVZ7Y42tf2CpqVW8T2lw5b8sWb7rHYGarI935qv2bgAAAIABfRnu0PkvysY6QJhUCD4ZKt3qZ6E1cYDivLhDb4GAZxmxSeN5cFPXU3Gst0oNmNjUW55rsZwZP+KkXi3NwAsTd9dZBxkcc+28m8Dr4hGtPTnPp+4p8wzw/X6Lmyr6RSykCK6xuv9rc2td+1fgNyPoWwcLZZQclDj+OdgQVHWj3A=="
+          )
+        )
+        assertEquals(key.keyType, SshKeyType.SshDsa)
+        assertEquals(key.ownerId, ownerId)
+      case _ => fail("PublicSshKey could not be created!")
+    }
+  }
 }