~jan0sch/smederee

Showing details for patch 165d002e8e6688c228dba115255aa39fa3171181.
2022-10-24 (Mon), 7:31 PM - Jens Grassel - 165d002e8e6688c228dba115255aa39fa3171181

SSH: Key-Management

- BREAKING: Add last_used column to ssh_keys table
- add endpoints for listing and adding ssh-keys
- extend functionality of AccountManagementRepository
- extend PublicSshKey
- HTML and CSS
Summary of changes
4 files added
  • modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub
  • modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub
  • modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html
10 files modified with 415 lines added and 89 lines removed
  • modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala with 99 added and 0 removed lines
  • modules/hub/src/main/resources/assets/css/main.css with 26 added and 0 removed lines
  • modules/hub/src/main/resources/db/migration/V1__base_tables.sql with 9 added and 7 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 15 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala with 35 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala with 116 added and 3 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala with 19 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala with 71 added and 73 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html with 17 added and 1 removed lines
  • modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala with 8 added and 5 removed lines
diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub
--- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-with-comment.pub	2025-02-01 22:02:23.224439541 +0000
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8ZU2xquZvstbesPktthwY2r5sanULBQKuM5bGHVdeP Some optional comment...
diff -rN -u old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub
--- old-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/resources/de/smederee/ssh/ssh-key-without-comment.pub	2025-02-01 22:02:23.224439541 +0000
@@ -0,0 +1 @@
+ssh-dss AAAAB3NzaC1kc3MAAACBAKn1DHh6DaIg/cN6vNVh1VXvHhH86eKelfsolIfvTPQSb3vkqoWPG3T3DGmrUjbqvrrfzaKILTBRv05KqMCbJKETGR0fuY7G1/Nkd/6dZjw1ngYkGd0fr2ERGuq87+gdd1A3TeIqvdjnl7MG3bEGf+fIEJOrRJraZ+u/tDFlSYq/AAAAFQCAUrv94uu1dVTTiyoagKV4Y4QWuQAAAIAuR5mFFYAgT1+t1u16eRCou1nPO4+q35/6uNNCyXtP0BmZaxXqQw25foJz5OzSQWXjjianfRfUyjsHt5DgM0PAIZaqmxMUiVw7BT7zUTa7ucl9NQmFBexiedCtokVb8++vHVZ7Y42tf2CpqVW8T2lw5b8sWb7rHYGarI935qv2bgAAAIABfRnu0PkvysY6QJhUCD4ZKt3qZ6E1cYDivLhDb4GAZxmxSeN5cFPXU3Gst0oNmNjUW55rsZwZP+KkXi3NwAsTd9dZBxkcc+28m8Dr4hGtPTnPp+4p8wzw/X6Lmyr6RSykCK6xuv9rc2td+1fgNyPoWwcLZZQclDj+OdgQVHWj3A== 
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala	2025-02-01 22:02:23.224439541 +0000
@@ -17,12 +17,14 @@
 
 package de.smederee.hub
 
+import java.time.{ OffsetDateTime, ZoneOffset }
 import java.util.UUID
 
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.hub.Generators._
 import de.smederee.hub.config.SmedereeHubConfig
+import de.smederee.ssh._
 import doobie._
 import org.flywaydb.core.Flyway
 
@@ -46,6 +48,80 @@
     val _ = flyway.clean()
   }
 
+  val sshKeyWithComment = ResourceSuiteLocalFixture(
+    "ssh-key-with-comment",
+    Resource.make(IO {
+      val input = scala.io.Source
+        .fromInputStream(
+          getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-with-comment.pub"),
+          "UTF-8"
+        )
+        .getLines()
+        .mkString
+      val keyString = SshPublicKeyString(input)
+      PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString)
+    })(_ => IO.unit)
+  )
+
+  val sshKeyWithoutComment = ResourceSuiteLocalFixture(
+    "ssh-key-without-comment",
+    Resource.make(IO {
+      val input = scala.io.Source
+        .fromInputStream(
+          getClass().getClassLoader().getResourceAsStream("de/smederee/ssh/ssh-key-without-comment.pub"),
+          "UTF-8"
+        )
+        .getLines()
+        .mkString
+      val keyString = SshPublicKeyString(input)
+      PublicSshKey.from(UUID.randomUUID())(UserId.randomUserId)(OffsetDateTime.now(ZoneOffset.UTC))(keyString)
+    })(_ => IO.unit)
+  )
+
+  override def munitFixtures = List(sshKeyWithComment, sshKeyWithoutComment)
+
+  test("addSshKey must save the key to the database") {
+    (genValidAccount.sample, sshKeyWithComment()) match {
+      case (Some(account), Some(sshKey)) =>
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val attempts = scala.util.Random.nextInt(128)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _    <- createAccount(account, hash, None, Option(attempts))
+          w    <- repo.addSshKey(sshKey.copy(ownerId = account.uid))
+          keys <- repo.listSshKeys(account.uid).compile.toList
+        } yield (w, keys)
+        test.map { result =>
+          val (written, keys) = result
+          assert(written === 1, "No database rows written!")
+          assert(keys.exists(_.id === sshKey.id), "Key must be in the key list of the user!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("addSshKey must fail if a key with the same fingerprint already exists") {
+    (genValidAccount.sample, sshKeyWithoutComment()) match {
+      case (Some(account), Some(sshKey)) =>
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val attempts = scala.util.Random.nextInt(128)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _ <- createAccount(account, hash, None, Option(attempts))
+          _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid))
+          _ <- repo.addSshKey(sshKey.copy(ownerId = account.uid))
+        } yield ()
+        test.attempt.map { result =>
+          assert(result.isLeft, "Writing a key with a duplicate fingerprint must fail!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("deleteAccount must remove the account from the database") {
     genValidAccount.sample match {
       case None => fail("Could not generate data samples!")
@@ -99,6 +175,29 @@
     }
   }
 
+  test("listSshKeys must return all keys for the user") {
+    (genValidAccount.sample, sshKeyWithComment(), sshKeyWithoutComment()) match {
+      case (Some(account), Some(sshKeyA), Some(sshKeyB)) =>
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieAccountManagementRepository[IO](tx)
+        val attempts = scala.util.Random.nextInt(128)
+        val hash     = PasswordHash("Yet another weak password!")
+        val test = for {
+          _    <- createAccount(account, hash, None, Option(attempts))
+          _    <- repo.addSshKey(sshKeyA.copy(ownerId = account.uid))
+          _    <- repo.addSshKey(sshKeyB.copy(ownerId = account.uid))
+          keys <- repo.listSshKeys(account.uid).compile.toList
+        } yield keys
+        test.map { keys =>
+          assertEquals(keys.length, 2, "Expected 2 keys in the key list!")
+          assert(keys.exists(_.id === sshKeyA.id), "Key A must be in the key list of the user!")
+          assert(keys.exists(_.id === sshKeyB.id), "Key B must be in the key list of the user!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("markAsValidated must clear the validation token and set the validated column to true") {
     genValidAccount.sample match {
       case None => fail("Could not generate data samples!")
diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css
--- old-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/resources/assets/css/main.css	2025-02-01 22:02:23.224439541 +0000
@@ -8,6 +8,15 @@
   padding: 0px 10px;
 }
 
+.add-ssh-key-form {
+  border: 1px solid black;
+  padding: 0px 10px;
+}
+
+.ssh-key-item-icon {
+  margin-right: 5px;
+}
+
 .validate-email-form {
   border: 1px solid black;
   padding: 0px 10px;
@@ -152,6 +161,11 @@
   word-wrap: break-word;
 }
 
+.account-settings-description {
+  background-color: #eee;
+  padding: 0em 0.5em 0em 0.5em;
+}
+
 .overview-latest-changes {
   font-size: 85%;
 }
@@ -229,6 +243,18 @@
   padding: 0;
 }
 
+.left-floated {
+  float: left;
+}
+
+.right-floated {
+  float: right;
+}
+
+.clearfix {
+  clear: both;
+}
+
 @media (min-width: 48em) {
 
   /* We increase the body font size */
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql	2025-02-01 22:02:23.224439541 +0000
@@ -58,13 +58,14 @@
 
 CREATE TABLE "ssh_keys"
 (
-  "id"          UUID                     NOT NULL, 
-  "uid"         UUID                     NOT NULL,
-  "key_type"    CHARACTER VARYING(32)    NOT NULL,
-  "key"         TEXT                     NOT NULL,
-  "fingerprint" CHARACTER VARYING(256)   NOT NULL,
-  "comment"     CHARACTER VARYING(256)   DEFAULT NULL,
-  "created_at"  TIMESTAMP WITH TIME ZONE NOT NULL,
+  "id"           UUID                     NOT NULL, 
+  "uid"          UUID                     NOT NULL,
+  "key_type"     CHARACTER VARYING(32)    NOT NULL,
+  "key"          TEXT                     NOT NULL,
+  "fingerprint"  CHARACTER VARYING(256)   NOT NULL,
+  "comment"      CHARACTER VARYING(256)   DEFAULT NULL,
+  "created_at"   TIMESTAMP WITH TIME ZONE NOT NULL,
+  "last_used_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL,
   CONSTRAINT "ssh_keys_pk"        PRIMARY KEY ("id"),
   CONSTRAINT "ssh_keys_unique_fp" UNIQUE ("fingerprint"),
   CONSTRAINT "ssh_keys_fk_uid"    FOREIGN KEY ("uid")
@@ -82,3 +83,4 @@
 COMMENT ON COLUMN "ssh_keys"."fingerprint" IS 'The fingerprint of the ssh key. It must be unique because a key can only be used by one account.';
 COMMENT ON COLUMN "ssh_keys"."comment" IS 'An optional comment for the ssh key limited to 256 characters.';
 COMMENT ON COLUMN "ssh_keys"."created_at" IS 'The timestamp of when the ssh key was created.';
+COMMENT ON COLUMN "ssh_keys"."last_used_at" IS 'The timestamp of when the ssh key was last used by the user.';
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-01 22:02:23.224439541 +0000
@@ -62,6 +62,13 @@
 form.signup.username=Username
 form.signup.username.help=A username is required because it will be used to group your projects and other stuff. It must be between 2 and 31 characters long and contain only lowercase alphanumeric characters and start with a character (letter).
 form.signup.username.placeholder=Please choose your username.
+form.ssh.add.button.reset=Cancel
+form.ssh.add.button.submit=Add SSH key
+form.ssh.add.key=Key content
+form.ssh.add.key.help=Please note that we currently only support RSA keys!
+form.ssh.add.key.placeholder=The content of your key file (usually located in ~/.ssh/id_rsa.pub) starting with ssh-rsa or ssh-ed25519.
+form.ssh.add.name=Name
+form.ssh.add.name.help=If left empty it will be taken from the ssh key comment (if that exists).
 
 # Global / generic translations
 global.alpha=Alpha Notice
@@ -175,5 +182,13 @@
 
 # User management / settings
 user.settings.account.delete.title=Delete your account
+user.settings.account.description=On this page you can manage your basic account settings and validate or delete your account.
+user.settings.account.title=Account
 user.settings.account.validate-email.title=Validate your email address
+user.settings.ssh.add.title=Add a new public ssh key.
+user.settings.ssh.description=Here you can manage your SSH keys.
+user.settings.ssh.key.created=Uploaded on {0,date,yyyy-MM-dd (E)}
+user.settings.ssh.key.last-used=Last used on {0,date,yyyy-MM-dd (E)}
+user.settings.ssh.title=SSH-Keys
+user.settings.title=Settings
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala	2025-02-01 22:02:23.224439541 +0000
@@ -17,6 +17,11 @@
 
 package de.smederee.hub
 
+import java.util.UUID
+
+import de.smederee.ssh.PublicSshKey
+import fs2.Stream
+
 /** The base class for database operations related to account management for users.
   *
   * @tparam F
@@ -24,6 +29,16 @@
   */
 abstract class AccountManagementRepository[F[_]] {
 
+  /** Add the given ssh key to the database. The database MUST ensure that a key is unique across the system! Usually by
+    * having a unique constraint on the fingerprint of the key.
+    *
+    * @param key
+    *   The public ssh key to be saved to the database.
+    * @return
+    *   The number of affected database rows.
+    */
+  def addSshKey(key: PublicSshKey): F[Int]
+
   /** Delete the account with the given user id from the database.
     *
     * The internal database logic (foreign keys, cascading) SHALL ensure that everything related to the user is deleted
@@ -36,6 +51,17 @@
     */
   def deleteAccount(uid: UserId): F[Int]
 
+  /** Delete the ssh key with the given id and owner from the database.
+    *
+    * @param keyId
+    *   The unique id of the public ssh key.
+    * @param ownerId
+    *   The unique user id of the owner of the ssh key.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteSshKey(keyId: UUID, ownerId: UserId): F[Int]
+
   /** Find the account with the given validation token.
     *
     * @param token
@@ -54,6 +80,15 @@
     */
   def findPasswordHash(uid: UserId): F[Option[PasswordHash]]
 
+  /** Return all public ssh keys for the given user.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   A stream of public ssh keys that may be empty.
+    */
+  def listSshKeys(uid: UserId): Stream[F, PublicSshKey]
+
   /** Mark the account with the given user id as validated. This includes the following operations in the database:
     *
     * {{{
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala	2025-02-01 22:02:23.224439541 +0000
@@ -19,6 +19,8 @@
 
 import java.io.IOException
 import java.nio.file.{ FileVisitResult, FileVisitor, Files }
+import java.time.{ OffsetDateTime, ZoneOffset }
+import java.util.UUID
 
 import cats._
 import cats.data._
@@ -28,7 +30,9 @@
 import de.smederee.html.LinkTools._
 import de.smederee.hub.RequestHelpers.instances.given
 import de.smederee.hub.config._
+import de.smederee.hub.forms.types._
 import de.smederee.security.SignAndValidate
+import de.smederee.ssh._
 import org.http4s._
 import org.http4s.dsl._
 import org.http4s.headers.Location
@@ -112,6 +116,91 @@
         }
     } yield deleted
 
+  private val addSshKey: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ POST -> Root / "user" / "settings" / "ssh" / "add" as user =>
+      ar.req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf <- Sync[F].delay(ar.req.getCsrfToken)
+          formData <- Sync[F].delay {
+            urlForm.values.map { t =>
+              val (key, values) = t
+              (
+                key,
+                values.headOption.getOrElse("")
+              ) // Pick the first value (a field might get submitted multiple times)!
+            }
+          }
+          form          <- Sync[F].delay(AddPublicSshKeyForm.validate(formData))
+          actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+          addAction     <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/add"))
+          deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/delete"))
+          keys          <- accountManagementRepo.listSshKeys(user.uid).compile.toList
+          resp <- form match {
+            case Validated.Invalid(errors) =>
+              BadRequest(
+                views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                  actionBaseUri,
+                  addAction,
+                  deleteAction,
+                  keys
+                )(formData, FormErrors.fromNec(errors))
+              )
+            case Validated.Valid(validSshKeyForm) =>
+              for {
+                id           <- Sync[F].delay(UUID.randomUUID())
+                createdAt    <- Sync[F].delay(OffsetDateTime.now(ZoneOffset.UTC))
+                convertedKey <- Sync[F].delay(PublicSshKey.from(id)(user.uid)(createdAt)(validSshKeyForm.keyString))
+                key <- Sync[F].delay(
+                  convertedKey.map(key => validSshKeyForm.name.fold(key)(name => key.copy(comment = Option(name))))
+                ) // Override comment with the one from the form if given.
+                written <- key.traverse(key => accountManagementRepo.addSshKey(key).recoverWith(_ => Sync[F].pure(0)))
+                resp <- written match {
+                  case None =>
+                    // There has been no write at all, implying that the conversion failed.
+                    BadRequest(
+                      views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                        actionBaseUri,
+                        addAction,
+                        deleteAction,
+                        keys
+                      )(
+                        formData,
+                        Map(
+                          AddPublicSshKeyForm.fieldGlobal -> List(
+                            FormFieldError("The key could not be properly converted!")
+                          )
+                        )
+                      )
+                    )
+                  case Some(1) =>
+                    // One row was written to the database implying that everything went well.
+                    SeeOther(Location(actionBaseUri.addSegment("ssh")))
+                  case Some(_) =>
+                    // Any other result implies that there has been an error.
+                    accountManagementRepo.listSshKeys(user.uid).compile.toList.flatMap { keys =>
+                      BadRequest(
+                        views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+                          actionBaseUri,
+                          addAction,
+                          deleteAction,
+                          keys
+                        )(
+                          formData,
+                          Map(
+                            AddPublicSshKeyForm.fieldGlobal -> List(
+                              FormFieldError("An error occured while saving the key to the database!")
+                            )
+                          )
+                        )
+                      )
+                    }
+                }
+              } yield resp
+          }
+        } yield resp
+      }
+  }
+
   private val deleteAccount: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ POST -> Root / "user" / "settings" / "delete" as user =>
       ar.req.decodeStrict[F, UrlForm] { urlForm =>
@@ -129,6 +218,7 @@
           passwordField <- Sync[F].delay(formData.get("password").flatMap(Password.from))
           userIsSure    <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes"))
           rootUri       <- Sync[F].delay(configuration.external.createFullUri(uri"/"))
+          actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
           deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
           validateAction <- Sync[F].delay(
             configuration.external.createFullUri(uri"user/settings/email/validate")
@@ -162,6 +252,7 @@
             } else
               BadRequest(
                 views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+                  actionBaseUri,
                   deleteAction,
                   validateAction
                 )
@@ -195,13 +286,15 @@
   private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
     case ar @ GET -> Root / "user" / "settings" as user =>
       for {
-        csrf         <- Sync[F].delay(ar.req.getCsrfToken)
-        deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
+        csrf          <- Sync[F].delay(ar.req.getCsrfToken)
+        actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+        deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete"))
         validateAction <- Sync[F].delay(
           configuration.external.createFullUri(uri"user/settings/email/validate")
         )
         resp <- Ok(
           views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)(
+            actionBaseUri,
             deleteAction,
             validateAction
           )
@@ -209,6 +302,25 @@
       } yield resp
   }
 
+  private val showAccountSshSettings: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case ar @ GET -> Root / "user" / "settings" / "ssh" as user =>
+      for {
+        csrf          <- Sync[F].delay(ar.req.getCsrfToken)
+        actionBaseUri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings"))
+        addAction     <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/add"))
+        deleteAction  <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/ssh/delete"))
+        keys          <- accountManagementRepo.listSshKeys(user.uid).compile.toList
+        resp <- Ok(
+          views.html.account.sshSettings()(csrf, Option(s"Smederee/~${user.name} - SSH Settings"), user)(
+            actionBaseUri,
+            addAction,
+            deleteAction,
+            keys
+          )()
+        )
+      } yield resp
+  }
+
   private val validateEmailAddress: HttpRoutes[F] = HttpRoutes.of {
     case req @ GET -> Root / "user" / "settings" / "email" / "validate" / ValidationTokenPathParameter(
           token
@@ -221,7 +333,8 @@
       } yield resp
   }
 
-  val protectedRoutes = deleteAccount <+> sendValidationEmail <+> showAccountSettings
+  val protectedRoutes =
+    addSshKey <+> deleteAccount <+> sendValidationEmail <+> showAccountSettings <+> showAccountSshSettings
 
   val routes = validateEmailAddress
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AddPublicSshKeyForm.scala	2025-02-01 22:02:23.224439541 +0000
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.data._
+import cats.syntax.all._
+import de.smederee.hub.forms._
+import de.smederee.hub.forms.types._
+import de.smederee.ssh._
+
+/** Data container for the form to add a new public ssh key.
+  *
+  * @param name
+  *   An optional name for the key.
+  * @param keyString
+  *   A string containing a valid public key.
+  */
+final case class AddPublicSshKeyForm(name: Option[KeyComment], keyString: SshPublicKeyString)
+
+object AddPublicSshKeyForm extends FormValidator[AddPublicSshKeyForm] {
+  val fieldName: FormField = FormField("name")
+  val fieldKey: FormField  = FormField("key")
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, AddPublicSshKeyForm] = {
+    val name = data.get(fieldName).fold(None.validNec)(name => KeyComment.from(name).validNec)
+    val key = data
+      .get(fieldKey)
+      .fold(FormFieldError("No ssh public key given!").invalidNec)(string =>
+        SshPublicKeyString.from(string.trim).fold(FormFieldError("Invalid ssh public key!").invalidNec)(_.validNec)
+      )
+      .leftMap(errors => NonEmptyChain.of(Map(fieldKey -> errors.toList)))
+    (name, key).mapN { case (validName, validKey) =>
+      AddPublicSshKeyForm(validName, validKey)
+    }
+  }
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala	2025-02-01 22:02:23.224439541 +0000
@@ -22,6 +22,7 @@
 import cats.effect._
 import cats.syntax.all._
 import de.smederee.hub.VcsMetadataRepositoriesOrdering._
+import de.smederee.ssh._
 import doobie._
 import doobie.Fragments._
 import doobie.implicits._
@@ -31,16 +32,28 @@
 
 final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F]) extends AccountManagementRepository[F] {
   given Meta[Email]           = Meta[String].timap(Email.apply)(_.toString)
+  given Meta[EncodedKeyBytes] = Meta[String].timap(EncodedKeyBytes.unsafeFrom)(_.toString)
+  given Meta[KeyComment]      = Meta[String].timap(KeyComment.apply)(_.toString)
+  given Meta[KeyFingerprint]  = Meta[String].timap(KeyFingerprint.apply)(_.toString)
   given Meta[PasswordHash]    = Meta[String].timap(PasswordHash.apply)(_.toString)
+  given Meta[SshKeyType]      = Meta[String].timap(id => SshKeyType.Mappings(id))(_.identifier)
   given Meta[UserId]          = Meta[UUID].timap(UserId.apply)(_.toUUID)
   given Meta[Username]        = Meta[String].timap(Username.apply)(_.toString)
   given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString)
 
   private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts""""
 
+  override def addSshKey(key: PublicSshKey): F[Int] =
+    sql"""INSERT INTO "ssh_keys" (id, uid, key_type, key, fingerprint, comment, created_at)
+      VALUES(${key.id}, ${key.ownerId}, ${key.keyType}, ${key.keyBytes}, ${key.fingerprint}, ${key.comment}, NOW())""".update.run
+      .transact(tx)
+
   override def deleteAccount(uid: UserId): F[Int] =
     sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx)
 
+  override def deleteSshKey(keyId: UUID, ownerId: UserId): F[Int] =
+    sql"""DELETE FROM "ssh_keys" WHERE id = $keyId AND uid = $ownerId""".update.run.transact(tx)
+
   override def findByValidationToken(token: ValidationToken): F[Option[Account]] = {
     val query = selectAccountColumns ++ fr"""WHERE validation_token = $token""" ++ fr"""LIMIT 1"""
     query.query[Account].option.transact(tx)
@@ -49,6 +62,12 @@
   override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] =
     sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx)
 
+  override def listSshKeys(uid: UserId): Stream[F, PublicSshKey] =
+    sql"""SELECT id, uid, key_type, key, fingerprint, comment, created_at, last_used_at FROM "ssh_keys" WHERE uid = $uid"""
+      .query[PublicSshKey]
+      .stream
+      .transact(tx)
+
   override def markAsValidated(uid: UserId): F[Int] =
     sql"""UPDATE "accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run
       .transact(tx)
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-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/PublicSshKey.scala	2025-02-01 22:02:23.224439541 +0000
@@ -18,6 +18,7 @@
 package de.smederee.ssh
 
 import java.security.MessageDigest
+import java.time.OffsetDateTime
 import java.util.{ Base64, UUID }
 
 import cats._
@@ -50,6 +51,19 @@
     */
   def from(source: String): Option[EncodedKeyBytes] = Option(source).filter(string => Format.matches(string))
 
+  /** An unsafe method 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
+    *   The converted EncodedKeyBytes instance.
+    */
+  @throws[IllegalArgumentException]("if the given string is not syntactically valid")
+  def unsafeFrom(source: String): EncodedKeyBytes =
+    from(source) match {
+      case None           => throw new IllegalArgumentException(s"Illegal format for EncodedKeyBytes: $source")
+      case Some(keyBytes) => keyBytes
+    }
 }
 
 extension (keyBytes: EncodedKeyBytes) {
@@ -62,79 +76,51 @@
   def toByteArray: Array[Byte] = Base64.getDecoder().decode(keyBytes)
 }
 
-opaque type KeyFingerprint = String
-object KeyFingerprint {
+opaque type KeyComment = String
+object KeyComment {
+  val MaxLength: Int = 256
 
-  /** Create an instance of KeyFingerprint from the given String type.
+  /** Create an instance of KeyComment from the given String type.
     *
     * @param source
-    *   An instance of type String which will be returned as a KeyFingerprint.
+    *   An instance of type String which will be returned as a KeyComment.
     * @return
-    *   The appropriate instance of KeyFingerprint.
+    *   The appropriate instance of KeyComment.
     */
-  def apply(source: String): KeyFingerprint = source
+  def apply(source: String): KeyComment = source
 
-  /** Try to create an instance of KeyFingerprint from the given String.
+  /** Try to create an instance of KeyComment from the given String.
     *
     * @param source
-    *   A String that should fulfil the requirements to be converted into a KeyFingerprint.
+    *   A String that should fulfil the requirements to be converted into a KeyComment.
     * @return
-    *   An option to the successfully converted KeyFingerprint.
+    *   An option to the successfully converted KeyComment.
     */
-  def from(source: String): Option[KeyFingerprint] = Option(source).filter(_.nonEmpty)
+  def from(source: String): Option[KeyComment] = Option(source).map(_.take(MaxLength)).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
+opaque type KeyFingerprint = String
+object KeyFingerprint {
 
-  /** Try to create an instance of PublicSshKeyId from the given UUID.
+  /** Create an instance of KeyFingerprint from the given String type.
     *
     * @param source
-    *   A UUID that should fulfil the requirements to be converted into a PublicSshKeyId.
+    *   An instance of type String which will be returned as a KeyFingerprint.
     * @return
-    *   An option to the successfully converted PublicSshKeyId.
+    *   The appropriate instance of KeyFingerprint.
     */
-  def from(source: UUID): Option[PublicSshKeyId] = Option(source)
+  def apply(source: String): KeyFingerprint = source
 
-  /** Try to create an instance of PublicSshKeyId from the given String.
+  /** 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 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.
-    *
+    *   A String that should fulfil the requirements to be converted into a KeyFingerprint.
     * @return
-    *   A key id which is pseudo randomly generated.
+    *   An option to the successfully converted KeyFingerprint.
     */
-  def randomId: PublicSshKeyId = UUID.randomUUID
-
-}
+  def from(source: String): Option[KeyFingerprint] = Option(source).filter(_.nonEmpty)
 
-extension (keyId: PublicSshKeyId) {
-  def toUUID: UUID = keyId
 }
 
 opaque type SshPublicKeyString = String
@@ -161,31 +147,34 @@
 }
 
 /** Possible ssh key types.
+  *
+  * @param identifier
+  *   A unique identifier (the code written into openssh key files).
   */
-enum SshKeyType {
-  case SshDsa
+enum SshKeyType(val identifier: String) {
+  case SshDsa extends SshKeyType("ssh-dss")
 
-  case SshEcDsa
+  case SshEcDsa extends SshKeyType("ecdsa-sha2-nistp256")
 
-  case SshEcDsaSk
+  // case SshEcDsaSk
 
   /** Recommended key type because smaller payload and more secure but some servers still don't support them.
     */
-  case SshEd25519
+  case SshEd25519 extends SshKeyType("ssh-ed25519")
 
-  case SshEd25519Sk
+  // case SshEd25519Sk
 
   /** RSA keys are (still) the most common ones and the most compatible.
     */
-  case SshRsa
+  case SshRsa extends SshKeyType("ssh-rsa")
 }
 
 object SshKeyType {
   val Mappings: Map[String, SshKeyType] = Map(
-    "ssh-dss"             -> SshDsa,
-    "ecdsa-sha2-nistp256" -> SshEcDsa,
-    "ssh-ed25519"         -> SshEd25519,
-    "ssh-rsa"             -> SshRsa
+    SshDsa.identifier     -> SshDsa,
+    SshEcDsa.identifier   -> SshEcDsa,
+    SshEd25519.identifier -> SshEd25519,
+    SshRsa.identifier     -> SshRsa
   )
 
   given Eq[SshKeyType] = Eq.fromUniversalEquals
@@ -219,17 +208,24 @@
   * @param keyBytes
   *   The actual key (base 64 encoded).
   * @param fingerprint
-  *   The fingerprint of the key.
+  *   The fingerprint of the key using SHA-256.
   * @param comment
-  *   An optional comment for the key, quite often this is an email address.
+  *   An optional comment for the key, quite often this is an email address. Currently this is limited to 256
+  *   characters.
+  * @param createdAt
+  *   The timestamp of when the ssh key was created.
+  * @param lastUsedAt
+  *   The timestamp of when the ssh key was last used by the user.
   */
 final case class PublicSshKey(
-    id: PublicSshKeyId,
+    id: UUID,
     ownerId: UserId,
     keyType: SshKeyType,
     keyBytes: EncodedKeyBytes,
     fingerprint: KeyFingerprint,
-    comment: Option[String]
+    comment: Option[KeyComment],
+    createdAt: OffsetDateTime,
+    lastUsedAt: Option[OffsetDateTime]
 )
 
 object PublicSshKey {
@@ -241,19 +237,21 @@
     *   The globally unique id of the ssh key.
     * @param ownerId
     *   The unique id of the user account associated with this key.
+    * @param createdAt
+    *   The timestamp of when the ssh key (this [[PublicSshKey]]) was created.
     * @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] = {
+  def from(id: UUID)(ownerId: UserId)(createdAt: OffsetDateTime)(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(" "))
+      case commentParts => KeyComment.from(commentParts.mkString(" "))
     }
     val fingerprint = base64Key.flatMap { base64Key =>
       val rawFingerprint = Try {
@@ -264,7 +262,7 @@
       rawFingerprint.flatMap(KeyFingerprint.from)
     }
     (keyType, base64Key, fingerprint).mapN { case (keyType, base64Key, fingerprint) =>
-      PublicSshKey(id, ownerId, keyType, base64Key, fingerprint, comment)
+      PublicSshKey(id, ownerId, keyType, base64Key, fingerprint, comment, createdAt, None)
     }
   }
 }
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-02-01 22:02:23.220439533 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html	2025-02-01 22:02:23.224439541 +0000
@@ -1,7 +1,23 @@
-@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri, validateAction: Uri)
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, deleteAction: Uri, validateAction: Uri)
 @main(baseUri, lang)()(csrf, title, user.some) {
 @defining(lang.toLocale) { implicit locale =>
 <div class="content">
+    <div class="pure-g">
+      <div class="pure-u-1-1 pure-u-md-1-1">
+        <div class="l-box-left-right">
+          <h2>~@user.name / Settings</h2>
+          <nav class="pure-menu pure-menu-horizontal">
+            <ul class="pure-menu-list">
+              <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
+              <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
+            </ul>
+          </nav>
+          <div class="account-settings-description">
+            @Messages("user.settings.account.description")
+          </div>
+        </div>
+      </div>
+    </div>
   <div class="pure-g">
     @if(user.validatedEmail) {
     } else {
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/sshSettings.scala.html	2025-02-01 22:02:23.224439541 +0000
@@ -0,0 +1,88 @@
+@import de.smederee.hub.AddPublicSshKeyForm._
+@import de.smederee.ssh.PublicSshKey
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(actionBaseUri: Uri, addAction: Uri, deleteAction: Uri, keys: List[PublicSshKey])(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
+@main(baseUri, lang)()(csrf, title, user.some) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box-left-right">
+        <h2>~@user.name / Settings</h2>
+        <nav class="pure-menu pure-menu-horizontal">
+          <ul class="pure-menu-list">
+            <li class="pure-menu-item"><a class="pure-menu-link" href="@actionBaseUri">@Messages("user.settings.account.title")</a></li>
+            <li class="pure-menu-item pure-menu-active"><a class="pure-menu-link" href="@actionBaseUri.addSegment("ssh")">@Messages("user.settings.ssh.title")</a></li>
+          </ul>
+        </nav>
+        <div class="account-settings-description">
+          @Messages("user.settings.ssh.description")
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="add-ssh-key-form">
+          <h4>@Messages("user.settings.ssh.add.title")</h4>
+          <div class="form-errors">
+            @formErrors.get(fieldGlobal).map { es =>
+              @for(error <- es) {
+                <p class="alert alert-error">
+                  <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+                  <span class="sr-only">Fehler:</span>
+                  @error
+                </p>
+              }
+            }
+          </div>
+          <form action="@addAction" class="pure-form" method="POST" accept-charset="UTF-8">
+            <fieldset class="pure-group">
+              <div class="pure-control-group">
+                <label for="@{fieldName}">@Messages("form.ssh.add.name")</label>
+                <input class="pure-input-1" id="@{fieldName}" name="@{fieldName}" maxlength="256" type="text" value="@{formData.get(fieldName)}">
+                <span class="pure-form-message" id="@{fieldName}.help">@Messages("form.ssh.add.name.help")</span>
+                @renderFormErrors(fieldName, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldKey}">@Messages("form.ssh.add.key")</label>
+                <textarea class="pure-input-1" id="@{fieldKey}" name="@{fieldKey}" placeholder="@Messages("form.ssh.add.key.placeholder")" maxlength="4096" rows="8">@{formData.get(fieldKey)}</textarea>
+                <span class="pure-form-message" id="@{fieldKey}.help"><strong>@Messages("form.ssh.add.key.help")</strong></span>
+                @renderFormErrors(fieldKey, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <button type="submit" class="pure-button pure-button-primary">@Messages("form.ssh.add.button.submit")</button>
+              <button type="reset" class="button-secondary pure-button">@Messages("form.ssh.add.button.reset")</button>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="ssh-key-list">
+        @for(key <- keys) {
+          <div class="ssh-key-item">
+            <div class="ssh-key-item-icon left-floated">
+              <i class="fa-solid fa-key fa-3x"></i>
+            </div>
+            <div class="ssh-key-item-details">
+              <strong>@key.comment</strong>
+              <pre>SHA256:@key.fingerprint</pre>
+              <div class="ssh-key-item-timestamps">
+                @Messages("user.settings.ssh.key.created", java.util.Date.from(key.createdAt.toInstant)) @key.lastUsedAt.map(timestamp => Messages("user.settings.ssh.key.last-used", java.util.Date.from(timestamp.toInstant)))
+              </div>
+            </div>
+            <div class="clearfix"></div>
+          </div>
+        }
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
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-01 22:02:23.224439541 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/PublicSshKeyTest.scala	2025-02-01 22:02:23.224439541 +0000
@@ -17,6 +17,9 @@
 
 package de.smederee.ssh
 
+import java.time.{ OffsetDateTime, ZoneOffset }
+import java.util.UUID
+
 import de.smederee.hub._
 
 import munit._
@@ -61,11 +64,11 @@
       .getLines()
       .mkString
     val sshKey  = SshPublicKeyString(input)
-    val id      = PublicSshKeyId.randomId
+    val id      = UUID.randomUUID()
     val ownerId = UserId.randomUserId
-    PublicSshKey.from(id)(ownerId)(sshKey) match {
+    PublicSshKey.from(id)(ownerId)(OffsetDateTime.now(ZoneOffset.UTC))(sshKey) match {
       case Some(key) =>
-        assertEquals(key.comment, Option("Some optional comment..."))
+        assertEquals(key.comment, Option(KeyComment("Some optional comment...")))
         assertEquals(key.fingerprint, KeyFingerprint("qduYGwQlx7kMHo7GBNwx6tULMTxbuEbDJ6pdgC88ZSo"))
         assertEquals(key.id, id)
         assertEquals(
@@ -87,9 +90,9 @@
       .getLines()
       .mkString
     val sshKey  = SshPublicKeyString(input)
-    val id      = PublicSshKeyId.randomId
+    val id      = UUID.randomUUID()
     val ownerId = UserId.randomUserId
-    PublicSshKey.from(id)(ownerId)(sshKey) match {
+    PublicSshKey.from(id)(ownerId)(OffsetDateTime.now(ZoneOffset.UTC))(sshKey) match {
       case Some(key) =>
         assertEquals(key.comment, None)
         assertEquals(key.fingerprint, KeyFingerprint("tPwxtBT8SN5nq1kbT/vERM/pkJUAtMP6j+sf3m75UqE"))