Add More Metadata to public ssh keys (#1182)

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
Mohammad Al Shakoush 2024-12-22 19:13:42 +01:00 committed by GitHub
parent 7a904dbb8c
commit 59884fbbe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 255 additions and 65 deletions

View file

@ -1,5 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::{DateTime, Utc};
use poem::web::Data; use poem::web::Data;
use poem_openapi::param::Path; use poem_openapi::param::Path;
use poem_openapi::payload::Json; use poem_openapi::payload::Json;
@ -19,6 +20,8 @@ use super::AnySecurityScheme;
struct ExistingPublicKeyCredential { struct ExistingPublicKeyCredential {
id: Uuid, id: Uuid,
label: String, label: String,
date_added: Option<DateTime<Utc>>,
last_used: Option<DateTime<Utc>>,
openssh_public_key: String, openssh_public_key: String,
} }
@ -32,6 +35,8 @@ impl From<PublicKeyCredential::Model> for ExistingPublicKeyCredential {
fn from(credential: PublicKeyCredential::Model) -> Self { fn from(credential: PublicKeyCredential::Model) -> Self {
Self { Self {
id: credential.id, id: credential.id,
date_added: credential.date_added,
last_used: credential.last_used,
label: credential.label, label: credential.label,
openssh_public_key: credential.openssh_public_key, openssh_public_key: credential.openssh_public_key,
} }
@ -115,6 +120,8 @@ impl ListApi {
let object = PublicKeyCredential::ActiveModel { let object = PublicKeyCredential::ActiveModel {
id: Set(Uuid::new_v4()), id: Set(Uuid::new_v4()),
user_id: Set(*user_id), user_id: Set(*user_id),
date_added: Set(Some(Utc::now())),
last_used: Set(None),
label: Set(body.label.clone()), label: Set(body.label.clone()),
..PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential::try_from(&*body)?) ..PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential::try_from(&*body)?)
} }
@ -158,6 +165,7 @@ impl DetailApi {
let model = PublicKeyCredential::ActiveModel { let model = PublicKeyCredential::ActiveModel {
id: Set(id.0), id: Set(id.0),
user_id: Set(*user_id), user_id: Set(*user_id),
date_added: Set(Some(Utc::now())),
label: Set(body.label.clone()), label: Set(body.label.clone()),
..<_>::from(UserPublicKeyCredential::try_from(&*body)?) ..<_>::from(UserPublicKeyCredential::try_from(&*body)?)
} }

View file

@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc;
use data_encoding::BASE64; use data_encoding::BASE64;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,
@ -381,4 +382,58 @@ impl ConfigProvider for DatabaseConfigProvider {
Ok(()) Ok(())
} }
async fn update_public_key_last_used(
&self,
credential: Option<AuthCredential>,
) -> Result<(), WarpgateError> {
let db = self.db.lock().await;
let Some(AuthCredential::PublicKey {
kind,
public_key_bytes,
}) = credential
else {
error!("Invalid or missing public key credential");
return Err(WarpgateError::InvalidCredentialType);
};
// Encode public key and match it against the database
let base64_bytes = data_encoding::BASE64.encode(&public_key_bytes);
let openssh_public_key = format!("{kind} {base64_bytes}");
debug!(
"Attempting to update last_used for public key: {}",
openssh_public_key
);
// Find the public key credential
let public_key_credential = entities::PublicKeyCredential::Entity::find()
.filter(
entities::PublicKeyCredential::Column::OpensshPublicKey
.eq(openssh_public_key.clone()),
)
.one(&*db)
.await?;
let Some(public_key_credential) = public_key_credential else {
warn!(
"Public key not found in the database: {}",
openssh_public_key
);
return Ok(()); // Gracefully return if the key is not found
};
// Update the `last_used` (last used) timestamp
let mut active_model: entities::PublicKeyCredential::ActiveModel =
public_key_credential.into();
active_model.last_used = Set(Some(Utc::now()));
active_model.update(&*db).await.map_err(|e| {
error!("Failed to update last_used for public key: {:?}", e);
WarpgateError::DatabaseError(e.into())
})?;
Ok(())
}
} }

View file

@ -47,6 +47,11 @@ pub trait ConfigProvider {
username: &str, username: &str,
target: &str, target: &str,
) -> Result<bool, WarpgateError>; ) -> Result<bool, WarpgateError>;
async fn update_public_key_last_used(
&self,
credential: Option<AuthCredential>,
) -> Result<(), WarpgateError>;
} }
//TODO: move this somewhere //TODO: move this somewhere

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use sea_orm::sea_query::ForeignKeyAction; use sea_orm::sea_query::ForeignKeyAction;
use sea_orm::Set; use sea_orm::Set;
@ -12,6 +13,8 @@ pub struct Model {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub label: String, pub label: String,
pub date_added: Option<DateTime<Utc>>,
pub last_used: Option<DateTime<Utc>>,
pub openssh_public_key: String, pub openssh_public_key: String,
} }

View file

@ -14,6 +14,7 @@ mod m00009_credential_models;
mod m00010_parameters; mod m00010_parameters;
mod m00011_rsa_key_algos; mod m00011_rsa_key_algos;
mod m00012_add_openssh_public_key_label; mod m00012_add_openssh_public_key_label;
mod m00013_add_openssh_public_key_dates;
pub struct Migrator; pub struct Migrator;
@ -33,6 +34,7 @@ impl MigratorTrait for Migrator {
Box::new(m00010_parameters::Migration), Box::new(m00010_parameters::Migration),
Box::new(m00011_rsa_key_algos::Migration), Box::new(m00011_rsa_key_algos::Migration),
Box::new(m00012_add_openssh_public_key_label::Migration), Box::new(m00012_add_openssh_public_key_label::Migration),
Box::new(m00013_add_openssh_public_key_dates::Migration),
] ]
} }
} }

View file

@ -21,9 +21,9 @@ impl MigrationTrait for Migration {
ColumnDef::new(Alias::new("label")) ColumnDef::new(Alias::new("label"))
.string() .string()
.not_null() .not_null()
.default("Public Key") .default("Public Key"),
) )
.to_owned() .to_owned(),
) )
.await .await
} }
@ -38,5 +38,4 @@ impl MigrationTrait for Migration {
) )
.await .await
} }
} }

View file

@ -0,0 +1,62 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00013_add_openssh_public_key_dates"
}
}
use crate::m00009_credential_models::public_key_credential;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Add 'date_added' column
manager
.alter_table(
Table::alter()
.table(public_key_credential::Entity)
.add_column(ColumnDef::new(Alias::new("date_added")).date_time().null())
.to_owned(),
)
.await?;
// Add 'last_used' column
manager
.alter_table(
Table::alter()
.table(public_key_credential::Entity)
.add_column(ColumnDef::new(Alias::new("last_used")).date_time().null())
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Drop 'last_used' column
manager
.alter_table(
Table::alter()
.table(public_key_credential::Entity)
.drop_column(Alias::new("last_used"))
.to_owned(),
)
.await?;
// Drop 'date_added' column
manager
.alter_table(
Table::alter()
.table(public_key_credential::Entity)
.drop_column(Alias::new("date_added"))
.to_owned(),
)
.await?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use http::StatusCode; use http::StatusCode;
use poem::web::Data; use poem::web::Data;
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse}; use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse};
@ -80,6 +81,8 @@ struct NewPublicKeyCredential {
struct ExistingPublicKeyCredential { struct ExistingPublicKeyCredential {
id: Uuid, id: Uuid,
label: String, label: String,
date_added: Option<DateTime<Utc>>,
last_used: Option<DateTime<Utc>>,
abbreviated: String, abbreviated: String,
} }
@ -91,8 +94,8 @@ fn abbreviate_public_key(k: &str) -> String {
format!( format!(
"{}...{}", "{}...{}",
&k[..l.min(k.len())], // Take the first `l` characters. &k[..l.min(k.len())], // Take the first `l` characters.
&k[k.len().saturating_sub(l)..] // Take the last `l` characters safely. &k[k.len().saturating_sub(l)..] // Take the last `l` characters safely.
) )
} }
@ -101,6 +104,8 @@ impl From<entities::PublicKeyCredential::Model> for ExistingPublicKeyCredential
Self { Self {
id: credential.id, id: credential.id,
label: credential.label, label: credential.label,
date_added: credential.date_added,
last_used: credential.last_used,
abbreviated: abbreviate_public_key(&credential.openssh_public_key), abbreviated: abbreviate_public_key(&credential.openssh_public_key),
} }
} }
@ -295,6 +300,8 @@ impl Api {
let object = PublicKeyCredential::ActiveModel { let object = PublicKeyCredential::ActiveModel {
id: Set(Uuid::new_v4()), id: Set(Uuid::new_v4()),
user_id: Set(user_model.id), user_id: Set(user_model.id),
date_added: Set(Some(Utc::now())),
last_used: Set(None),
label: Set(body.label.clone()), label: Set(body.label.clone()),
openssh_public_key: Set(body.openssh_public_key.clone()), openssh_public_key: Set(body.openssh_public_key.clone()),
} }

View file

@ -1222,18 +1222,28 @@ impl ServerSession {
key.public_key_base64() key.public_key_base64()
); );
let result = self let key = Some(AuthCredential::PublicKey {
.try_auth_lazy( kind: key.algorithm(),
&selector, public_key_bytes: Bytes::from(key.public_key_bytes()),
Some(AuthCredential::PublicKey { });
kind: key.algorithm(),
public_key_bytes: Bytes::from(key.public_key_bytes()), let result = self.try_auth_lazy(&selector, key.clone()).await;
}),
)
.await;
match result { match result {
Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Accepted { .. }) => {
// Update last_used timestamp
if let Err(err) = self
.services
.config_provider
.lock()
.await
.update_public_key_last_used(key.clone())
.await
{
warn!(?err, "Failed to update last_used for public key");
}
russh::server::Auth::Accept
}
Ok(AuthResult::Rejected) => russh::server::Auth::Reject { Ok(AuthResult::Rejected) => russh::server::Auth::Reject {
proceed_with_methods: Some(MethodSet::all()), proceed_with_methods: Some(MethodSet::all()),
}, },

View file

@ -20,6 +20,7 @@
import CreateOtpModal from './CreateOtpModal.svelte' import CreateOtpModal from './CreateOtpModal.svelte'
import AuthPolicyEditor from './AuthPolicyEditor.svelte' import AuthPolicyEditor from './AuthPolicyEditor.svelte'
import { possibleCredentials } from 'common/protocols' import { possibleCredentials } from 'common/protocols'
import CredentialUsedStateBadge from 'common/CredentialUsedStateBadge.svelte'
interface Props { interface Props {
userId: string userId: string
@ -248,27 +249,32 @@
<div class="list-group-item credential"> <div class="list-group-item credential">
{#if credential.kind === CredentialKind.Password } {#if credential.kind === CredentialKind.Password }
<Fa fw icon={faKeyboard} /> <Fa fw icon={faKeyboard} />
<span class="type">Password</span> <span class="label me-auto">Password</span>
{/if} {/if}
{#if credential.kind === 'PublicKey'} {#if credential.kind === 'PublicKey'}
<Fa fw icon={faKey} /> <Fa fw icon={faKey} />
<span class="type">{credential.label}</span> <div class="main me-auto">
<span class="text-muted ms-2">{abbreviatePublicKey(credential.opensshPublicKey)}</span> <div class="label d-flex align-items-center">
{credential.label}
</div>
<small class="d-block text-muted">{abbreviatePublicKey(credential.opensshPublicKey)}</small>
</div>
<CredentialUsedStateBadge credential={credential} />
<div class="me-2"></div>
{/if} {/if}
{#if credential.kind === 'Totp'} {#if credential.kind === 'Totp'}
<Fa fw icon={faMobileScreen} /> <Fa fw icon={faMobileScreen} />
<span class="type">One-time password</span> <span class="label me-auto">One-time password</span>
{/if} {/if}
{#if credential.kind === CredentialKind.Sso} {#if credential.kind === CredentialKind.Sso}
<Fa fw icon={faIdBadge} /> <Fa fw icon={faIdBadge} />
<span class="type">Single sign-on</span> <span class="label">Single sign-on</span>
<span class="text-muted ms-2"> <span class="text-muted ms-2 me-auto">
{credential.email} {credential.email}
{#if credential.provider} ({credential.provider}){/if} {#if credential.provider} ({credential.provider}){/if}
</span> </span>
{/if} {/if}
<span class="ms-auto"></span>
{#if credential.kind === CredentialKind.PublicKey || credential.kind === CredentialKind.Sso} {#if credential.kind === CredentialKind.PublicKey || credential.kind === CredentialKind.Sso}
<a <a
class="ms-2" class="ms-2"
@ -362,16 +368,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
.type { .label:not(:first-child), .main {
margin-left: .5rem; margin-left: .75rem;
}
a {
display: none;
}
&:hover a {
display: initial;
} }
} }
</style> </style>

View file

@ -82,8 +82,8 @@
<div> <div>
<h1>session</h1> <h1>session</h1>
<div class="d-flex align-items-center mt-1"> <div class="d-flex align-items-center mt-1">
<Tooltip delay="500" target="usernameBadge" animation>Authenticated user</Tooltip> <Tooltip delay="250" target="usernameBadge" animation>Authenticated user</Tooltip>
<Tooltip delay="500" target="targetBadge" animation>Selected target</Tooltip> <Tooltip delay="250" target="targetBadge" animation>Selected target</Tooltip>
<Badge id="usernameBadge" color="success" class="me-2 d-flex align-items-center"> <Badge id="usernameBadge" color="success" class="me-2 d-flex align-items-center">
{#if session.username} {#if session.username}

View file

@ -2165,6 +2165,14 @@
"label": { "label": {
"type": "string" "type": "string"
}, },
"date_added": {
"type": "string",
"format": "date-time"
},
"last_used": {
"type": "string",
"format": "date-time"
},
"openssh_public_key": { "openssh_public_key": {
"type": "string" "type": "string"
} }

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { uuid } from './sveltestrap-s5-ports/_sveltestrapUtils'
import Badge from './sveltestrap-s5-ports/Badge.svelte'
import Tooltip from './sveltestrap-s5-ports/Tooltip.svelte'
interface DatedCredential {
lastUsed?: Date
dateAdded?: Date
}
export let credential: DatedCredential
const id = uuid()
const lastUseThreshold = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
let badge: HTMLElement | undefined
</script>
<span bind:this={badge}>
{#if credential.lastUsed}
{#if credential.lastUsed.getTime() < lastUseThreshold.getTime()}
<Badge id={id} color="warning">Not used recently</Badge>
{:else}
<Badge id={id} color="success">Used recently</Badge>
{/if}
{:else}
<Badge id={id} color="warning">Never used</Badge>
{/if}
</span>
{#if credential.dateAdded || credential.lastUsed}
<Tooltip target={badge} animation delay="250">
{#if credential.dateAdded}
<div>Added on: {new Date(credential.dateAdded).toLocaleString()}</div>
{/if}
{#if credential.lastUsed}
<div>Last used: {new Date(credential.lastUsed).toLocaleString()}</div>
{/if}
</Tooltip>
{/if}

View file

@ -7,9 +7,9 @@
import { faIdBadge, faKey, faKeyboard, faMobilePhone } from '@fortawesome/free-solid-svg-icons' import { faIdBadge, faKey, faKeyboard, faMobilePhone } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa' import Fa from 'svelte-fa'
import PublicKeyCredentialModal from 'admin/PublicKeyCredentialModal.svelte' import PublicKeyCredentialModal from 'admin/PublicKeyCredentialModal.svelte'
import { Button } from '@sveltestrap/sveltestrap'
import CreatePasswordModal from 'admin/CreatePasswordModal.svelte' import CreatePasswordModal from 'admin/CreatePasswordModal.svelte'
import CreateOtpModal from 'admin/CreateOtpModal.svelte' import CreateOtpModal from 'admin/CreateOtpModal.svelte'
import CredentialUsedStateBadge from 'common/CredentialUsedStateBadge.svelte'
let error: string|null = $state(null) let error: string|null = $state(null)
let creds: CredentialsState | undefined = $state() let creds: CredentialsState | undefined = $state()
@ -113,9 +113,9 @@
<div class="d-flex align-items-center mt-4 mb-2"> <div class="d-flex align-items-center mt-4 mb-2">
<h4 class="m-0">One-time passwords</h4> <h4 class="m-0">One-time passwords</h4>
<span class="ms-auto"></span> <span class="ms-auto"></span>
<Button size="sm" color="link" on:click={() => { <a href={''} color="link" onclick={() => {
creatingOtpCredential = true creatingOtpCredential = true
}}>Add device</Button> }}>Add device</a>
</div> </div>
<div class="list-group list-group-flush mb-3"> <div class="list-group list-group-flush mb-3">
@ -125,7 +125,7 @@
<span class="label">OTP device</span> <span class="label">OTP device</span>
<span class="ms-auto"></span> <span class="ms-auto"></span>
<a <a
class="hover-reveal ms-2" class="ms-2"
href={''} href={''}
onclick={e => { onclick={e => {
deleteOtp(credential) deleteOtp(credential)
@ -147,20 +147,23 @@
<div class="d-flex align-items-center mt-4 mb-2"> <div class="d-flex align-items-center mt-4 mb-2">
<h4 class="m-0">Public keys</h4> <h4 class="m-0">Public keys</h4>
<span class="ms-auto"></span> <span class="ms-auto"></span>
<Button size="sm" color="link" on:click={() => { <a href={''} color="link" onclick={() => {
creatingPublicKeyCredential = true creatingPublicKeyCredential = true
}}>Add key</Button> }}>Add key</a>
</div> </div>
<div class="list-group list-group-flush mb-3"> <div class="list-group list-group-flush mb-3">
{#each creds.publicKeys as credential} {#each creds.publicKeys as credential}
<div class="list-group-item credential"> <div class="list-group-item credential">
<Fa fw icon={faKey} /> <Fa fw icon={faKey} />
<span class="label">{credential.label}</span> <div class="main">
<span class="text-muted ms-2">{credential.abbreviated}</span> <div class="label">{credential.label}</div>
<small class="d-block text-muted">{credential.abbreviated}</small>
</div>
<span class="ms-auto"></span> <span class="ms-auto"></span>
<CredentialUsedStateBadge credential={credential} />
<a <a
class="hover-reveal ms-2" class="ms-2"
href={''} href={''}
onclick={e => { onclick={e => {
deletePublicKey(credential) deletePublicKey(credential)
@ -230,16 +233,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
.label:not(:first-child) { .label:not(:first-child), .main {
margin-left: .5rem; margin-left: .75rem;
}
a.hover-reveal {
display: none;
}
&:hover a {
display: initial;
} }
} }
</style> </style>

View file

@ -699,6 +699,14 @@
"label": { "label": {
"type": "string" "type": "string"
}, },
"date_added": {
"type": "string",
"format": "date-time"
},
"last_used": {
"type": "string",
"format": "date-time"
},
"abbreviated": { "abbreviated": {
"type": "string" "type": "string"
} }

View file

@ -226,10 +226,7 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
store.http.key = data_path store.http.key = data_path.join("tls.key.pem").to_string_lossy().to_string();
.join("tls.key.pem")
.to_string_lossy()
.to_string();
store.mysql.certificate = store.http.certificate.clone(); store.mysql.certificate = store.http.certificate.clone();
store.mysql.key = store.http.key.clone(); store.mysql.key = store.http.key.clone();
@ -239,10 +236,7 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> {
// --- // ---
store.ssh.keys = data_path store.ssh.keys = data_path.join("ssh-keys").to_string_lossy().to_string();
.join("ssh-keys")
.to_string_lossy()
.to_string();
// --- // ---
@ -257,10 +251,7 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> {
.with_prompt("Do you want to record user sessions?") .with_prompt("Do you want to record user sessions?")
.interact()?; .interact()?;
} }
store.recordings.path = data_path store.recordings.path = data_path.join("recordings").to_string_lossy().to_string();
.join("recordings")
.to_string_lossy()
.to_string();
// --- // ---