~jan0sch/smederee
Showing details for patch 4c636082a6d4f68abc4c9e0bb5ca2f64f266d750.
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!") + } + } }