From 29e518152dbc5f56a764720f1179b694b42cb10d Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:29:35 -0500 Subject: [PATCH] feat: choose what an ssh key is for: authentication, signing or both Signed-off-by: d3vyce <44915747+d3vyce@users.noreply.github.com> --- ...df49c0f7ddd330939e9dd54b0edbe35fb9d6.json} | 16 ++- ...4cd785397ff7fe6c70084bafcb61b051d28f.json} | 16 ++- ...01a29698cd12174539e8fb3af17a90931242.json} | 7 +- ...53df557959d1ed952b3663b0664c0383a4497.json | 136 ++++++++++++++++++ .../20261113000000_ssh_key_usage.sql | 8 ++ crates/forgust-db/src/ssh_keys.rs | 88 +++++++++++- crates/forgust-server/src/api/openapi.rs | 2 +- crates/forgust-server/src/api/projects.rs | 5 +- crates/forgust-server/src/api/user.rs | 16 ++- .../forgust-server/src/services/push_rules.rs | 9 +- .../forgust-server/src/services/signatures.rs | 6 +- crates/forgust-server/src/ssh.rs | 18 ++- crates/forgust-server/tests/m1_repos.rs | 28 +++- crates/forgust-server/tests/m8_review.rs | 29 +++- web/openapi.json | 28 +++- web/src/api/schema.d.ts | 11 +- web/src/components/HoverCards.tsx | 59 ++++++-- web/src/components/ui.tsx | 2 +- web/src/pages/Profile.tsx | 66 ++++++++- web/src/pages/project/Code.tsx | 3 + web/src/pages/project/History.tsx | 35 +---- web/src/pages/project/MergeRequests.tsx | 3 +- web/src/pages/project/Signatures.tsx | 39 +++++ web/src/styles.css | 6 + 24 files changed, 555 insertions(+), 81 deletions(-) rename .sqlx/{query-c31a6132f713c86706793b43f3e7b449963e928c0b13463c5c8fc336a0b318b2.json => query-11f92fb586712d4ab1e28500eec4df49c0f7ddd330939e9dd54b0edbe35fb9d6.json} (83%) rename .sqlx/{query-e44e3f063de434f4d47f62abfebdaf4032aff3977a410aad97f42d48c0503955.json => query-76b239fbaf5d6333b003ee78bfac4cd785397ff7fe6c70084bafcb61b051d28f.json} (83%) rename .sqlx/{query-ef654fc813c0d4892fd255c329764a047925da20bb80e633c972bfa694d0bc28.json => query-7fe93ac19b2f1fd401af6a72890001a29698cd12174539e8fb3af17a90931242.json} (71%) create mode 100644 .sqlx/query-8fd7157b6d58b20484ecc860f9753df557959d1ed952b3663b0664c0383a4497.json create mode 100644 crates/forgust-db/migrations/20261113000000_ssh_key_usage.sql create mode 100644 web/src/pages/project/Signatures.tsx diff --git a/.sqlx/query-c31a6132f713c86706793b43f3e7b449963e928c0b13463c5c8fc336a0b318b2.json b/.sqlx/query-11f92fb586712d4ab1e28500eec4df49c0f7ddd330939e9dd54b0edbe35fb9d6.json similarity index 83% rename from .sqlx/query-c31a6132f713c86706793b43f3e7b449963e928c0b13463c5c8fc336a0b318b2.json rename to .sqlx/query-11f92fb586712d4ab1e28500eec4df49c0f7ddd330939e9dd54b0edbe35fb9d6.json index 9f137c2..69399f4 100644 --- a/.sqlx/query-c31a6132f713c86706793b43f3e7b449963e928c0b13463c5c8fc336a0b318b2.json +++ b/.sqlx/query-11f92fb586712d4ab1e28500eec4df49c0f7ddd330939e9dd54b0edbe35fb9d6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at FROM ssh_keys\n WHERE fingerprint = $1 AND (expires_at IS NULL OR expires_at > now())", + "query": "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS \"usage_type: KeyUsage\" FROM ssh_keys\n WHERE fingerprint = $1", "describe": { "columns": [ { @@ -101,6 +101,17 @@ "name": "created_at" } } + }, + { + "ordinal": 9, + "name": "usage_type: KeyUsage", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "usage_type" + } + } } ], "parameters": { @@ -117,8 +128,9 @@ false, true, true, + false, false ] }, - "hash": "c31a6132f713c86706793b43f3e7b449963e928c0b13463c5c8fc336a0b318b2" + "hash": "11f92fb586712d4ab1e28500eec4df49c0f7ddd330939e9dd54b0edbe35fb9d6" } diff --git a/.sqlx/query-e44e3f063de434f4d47f62abfebdaf4032aff3977a410aad97f42d48c0503955.json b/.sqlx/query-76b239fbaf5d6333b003ee78bfac4cd785397ff7fe6c70084bafcb61b051d28f.json similarity index 83% rename from .sqlx/query-e44e3f063de434f4d47f62abfebdaf4032aff3977a410aad97f42d48c0503955.json rename to .sqlx/query-76b239fbaf5d6333b003ee78bfac4cd785397ff7fe6c70084bafcb61b051d28f.json index 378d6f2..633dddd 100644 --- a/.sqlx/query-e44e3f063de434f4d47f62abfebdaf4032aff3977a410aad97f42d48c0503955.json +++ b/.sqlx/query-76b239fbaf5d6333b003ee78bfac4cd785397ff7fe6c70084bafcb61b051d28f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at FROM ssh_keys\n WHERE user_id = $1 ORDER BY created_at DESC", + "query": "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS \"usage_type: KeyUsage\" FROM ssh_keys\n WHERE user_id = $1 ORDER BY created_at DESC", "describe": { "columns": [ { @@ -101,6 +101,17 @@ "name": "created_at" } } + }, + { + "ordinal": 9, + "name": "usage_type: KeyUsage", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "usage_type" + } + } } ], "parameters": { @@ -117,8 +128,9 @@ false, true, true, + false, false ] }, - "hash": "e44e3f063de434f4d47f62abfebdaf4032aff3977a410aad97f42d48c0503955" + "hash": "76b239fbaf5d6333b003ee78bfac4cd785397ff7fe6c70084bafcb61b051d28f" } diff --git a/.sqlx/query-ef654fc813c0d4892fd255c329764a047925da20bb80e633c972bfa694d0bc28.json b/.sqlx/query-7fe93ac19b2f1fd401af6a72890001a29698cd12174539e8fb3af17a90931242.json similarity index 71% rename from .sqlx/query-ef654fc813c0d4892fd255c329764a047925da20bb80e633c972bfa694d0bc28.json rename to .sqlx/query-7fe93ac19b2f1fd401af6a72890001a29698cd12174539e8fb3af17a90931242.json index 828476b..fb52380 100644 --- a/.sqlx/query-ef654fc813c0d4892fd255c329764a047925da20bb80e633c972bfa694d0bc28.json +++ b/.sqlx/query-7fe93ac19b2f1fd401af6a72890001a29698cd12174539e8fb3af17a90931242.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO ssh_keys (kind, user_id, title, public_key, fingerprint, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "query": "INSERT INTO ssh_keys (kind, user_id, title, public_key, fingerprint, expires_at, usage_type) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", "describe": { "columns": [ { @@ -22,12 +22,13 @@ "Text", "Text", "Text", - "Timestamptz" + "Timestamptz", + "Text" ] }, "nullable": [ false ] }, - "hash": "ef654fc813c0d4892fd255c329764a047925da20bb80e633c972bfa694d0bc28" + "hash": "7fe93ac19b2f1fd401af6a72890001a29698cd12174539e8fb3af17a90931242" } diff --git a/.sqlx/query-8fd7157b6d58b20484ecc860f9753df557959d1ed952b3663b0664c0383a4497.json b/.sqlx/query-8fd7157b6d58b20484ecc860f9753df557959d1ed952b3663b0664c0383a4497.json new file mode 100644 index 0000000..1bca89f --- /dev/null +++ b/.sqlx/query-8fd7157b6d58b20484ecc860f9753df557959d1ed952b3663b0664c0383a4497.json @@ -0,0 +1,136 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS \"usage_type: KeyUsage\" FROM ssh_keys\n WHERE fingerprint = $1 AND (expires_at IS NULL OR expires_at > now())", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "kind", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "kind" + } + } + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "user_id" + } + } + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "title" + } + } + }, + { + "ordinal": 4, + "name": "public_key", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "public_key" + } + } + }, + { + "ordinal": 5, + "name": "fingerprint", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "fingerprint" + } + } + }, + { + "ordinal": 6, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "expires_at" + } + } + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "last_used_at" + } + } + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "created_at" + } + } + }, + { + "ordinal": 9, + "name": "usage_type: KeyUsage", + "type_info": "Text", + "origin": { + "Table": { + "table": "ssh_keys", + "name": "usage_type" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "8fd7157b6d58b20484ecc860f9753df557959d1ed952b3663b0664c0383a4497" +} diff --git a/crates/forgust-db/migrations/20261113000000_ssh_key_usage.sql b/crates/forgust-db/migrations/20261113000000_ssh_key_usage.sql new file mode 100644 index 0000000..8961921 --- /dev/null +++ b/crates/forgust-db/migrations/20261113000000_ssh_key_usage.sql @@ -0,0 +1,8 @@ +-- What an SSH key is for, as in GitLab's `usage_type`: signing in over SSH +-- (`auth`), verifying signed commits (`signing`), or both. Existing personal +-- keys did both; deploy keys only sign in (the CHECK holds them to it). +ALTER TABLE ssh_keys ADD COLUMN usage_type text NOT NULL DEFAULT 'auth_and_signing' + CHECK (usage_type IN ('auth', 'signing', 'auth_and_signing')); +UPDATE ssh_keys SET usage_type = 'auth' WHERE kind = 'deploy'; +ALTER TABLE ssh_keys ADD CONSTRAINT ssh_keys_deploy_keys_only_sign_in + CHECK (kind <> 'deploy' OR usage_type = 'auth'); diff --git a/crates/forgust-db/src/ssh_keys.rs b/crates/forgust-db/src/ssh_keys.rs index 71a05ea..0e5bf24 100644 --- a/crates/forgust-db/src/ssh_keys.rs +++ b/crates/forgust-db/src/ssh_keys.rs @@ -1,9 +1,53 @@ use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::PgExecutor; +use sqlx::postgres::{PgTypeInfo, PgValueRef, Postgres}; use crate::Result; +/// What an SSH key is for, as GitLab's `usage_type`: signing in over SSH, +/// verifying the commits its owner signs, or both. Deploy keys only sign in +/// (the table's CHECK holds them to it). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum KeyUsage { + Auth, + Signing, + AuthAndSigning, +} + +impl KeyUsage { + pub fn as_str(self) -> &'static str { + match self { + KeyUsage::Auth => "auth", + KeyUsage::Signing => "signing", + KeyUsage::AuthAndSigning => "auth_and_signing", + } + } +} + +// stored as text: the column's CHECK lists the same values +impl sqlx::Type for KeyUsage { + fn type_info() -> PgTypeInfo { + <&str as sqlx::Type>::type_info() + } + + fn compatible(ty: &PgTypeInfo) -> bool { + <&str as sqlx::Type>::compatible(ty) + } +} + +impl<'r> sqlx::Decode<'r, Postgres> for KeyUsage { + fn decode(value: PgValueRef<'r>) -> std::result::Result { + match <&str as sqlx::Decode>::decode(value)? { + "auth" => Ok(KeyUsage::Auth), + "signing" => Ok(KeyUsage::Signing), + "auth_and_signing" => Ok(KeyUsage::AuthAndSigning), + other => Err(format!("unknown SSH key usage {other:?}").into()), + } + } +} + #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct SshKey { pub id: i64, @@ -15,8 +59,22 @@ pub struct SshKey { pub expires_at: Option>, pub last_used_at: Option>, pub created_at: DateTime, + pub usage_type: KeyUsage, +} + +impl SshKey { + /// The key may sign in over SSH. + pub fn for_auth(&self) -> bool { + self.usage_type != KeyUsage::Signing + } + + /// The key verifies the commits its owner signs. + pub fn for_signing(&self) -> bool { + self.usage_type != KeyUsage::Auth + } } +#[allow(clippy::too_many_arguments)] pub async fn insert( e: impl PgExecutor<'_>, kind: &str, @@ -25,25 +83,41 @@ pub async fn insert( public_key: &str, fingerprint: &str, expires_at: Option>, + usage_type: KeyUsage, ) -> Result { sqlx::query_scalar!( - "INSERT INTO ssh_keys (kind, user_id, title, public_key, fingerprint, expires_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", + "INSERT INTO ssh_keys (kind, user_id, title, public_key, fingerprint, expires_at, usage_type) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", kind, user_id, title, public_key, fingerprint, - expires_at + expires_at, + usage_type.as_str() ) .fetch_one(e) .await } +/// A key that has not expired (for signing in, and for adding keys). pub async fn find_by_fingerprint(e: impl PgExecutor<'_>, fingerprint: &str) -> Result> { sqlx::query_as!( SshKey, - "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at FROM ssh_keys - WHERE fingerprint = $1 AND (expires_at IS NULL OR expires_at > now())", + r#"SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS "usage_type: KeyUsage" FROM ssh_keys + WHERE fingerprint = $1 AND (expires_at IS NULL OR expires_at > now())"#, + fingerprint + ) + .fetch_optional(e) + .await +} + +/// A key, expired or not: the commits it signed stay verified once it expires +/// (deleting it is what takes that back). +pub async fn find_signing_key(e: impl PgExecutor<'_>, fingerprint: &str) -> Result> { + sqlx::query_as!( + SshKey, + r#"SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS "usage_type: KeyUsage" FROM ssh_keys + WHERE fingerprint = $1"#, fingerprint ) .fetch_optional(e) @@ -63,8 +137,8 @@ pub async fn touch(e: impl PgExecutor<'_>, id: i64) -> Result<()> { pub async fn list_for_user(e: impl PgExecutor<'_>, user_id: i64) -> Result> { sqlx::query_as!( SshKey, - "SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at FROM ssh_keys - WHERE user_id = $1 ORDER BY created_at DESC", + r#"SELECT id, kind, user_id, title, public_key, fingerprint, expires_at, last_used_at, created_at, usage_type AS "usage_type: KeyUsage" FROM ssh_keys + WHERE user_id = $1 ORDER BY created_at DESC"#, user_id ) .fetch_all(e) diff --git a/crates/forgust-server/src/api/openapi.rs b/crates/forgust-server/src/api/openapi.rs index f6ebfa8..898684e 100644 --- a/crates/forgust-server/src/api/openapi.rs +++ b/crates/forgust-server/src/api/openapi.rs @@ -63,7 +63,7 @@ use super::{ forgust_db::admin::BlobScan, forgust_db::admin::PrefixUsage, forgust_db::admin::TaskQueue, forgust_db::admin::FailedJob, dto::Resolved, dto::UserDto, dto::GroupDto, dto::GroupDetail, dto::OrgPolicyDto, dto::Breadcrumb, dto::ProjectDto, dto::ProjectDetail, dto::MemberDto, dto::ShareDto, dto::ProtectedBranchDto, dto::NewToken, dto::AccessTokenDto, - forgust_db::ssh_keys::SshKey, forgust_db::ssh_keys::DeployKey, forgust_db::tokens::AccessToken, forgust_db::tokens::DeployToken, + forgust_db::ssh_keys::SshKey, forgust_db::ssh_keys::KeyUsage, forgust_db::ssh_keys::DeployKey, forgust_db::tokens::AccessToken, forgust_db::tokens::DeployToken, forgust_db::audit::AuditEvent, forgust_db::settings::InstanceSettings, forgust_storage::TreeEntry, forgust_storage::EntryKind, forgust_storage::CommitInfo, forgust_storage::CommitSummary, forgust_storage::Person, forgust_storage::BlameHunk, forgust_storage::Diff, forgust_storage::DiffFile, forgust_storage::DiffHunk, forgust_storage::DiffLine, diff --git a/crates/forgust-server/src/api/projects.rs b/crates/forgust-server/src/api/projects.rs index d107276..9a3abad 100644 --- a/crates/forgust-server/src/api/projects.rs +++ b/crates/forgust-server/src/api/projects.rs @@ -369,7 +369,10 @@ pub async fn add_deploy_key( // an existing deploy key can be attached to more projects; a user key cannot Some(k) if k.kind == "deploy" => k.id, Some(_) => return Err(ApiError::conflict("this key is already used as a personal key")), - None => forgust_db::ssh_keys::insert(&state.db, "deploy", None, &req.title, &key, &fp, None).await?, + None => { + forgust_db::ssh_keys::insert(&state.db, "deploy", None, &req.title, &key, &fp, None, forgust_db::ssh_keys::KeyUsage::Auth) + .await? + } }; forgust_db::ssh_keys::link_deploy_key(&state.db, key_id, proj.id, req.can_push).await?; services::audit( diff --git a/crates/forgust-server/src/api/user.rs b/crates/forgust-server/src/api/user.rs index b3f4b48..e1bc70f 100644 --- a/crates/forgust-server/src/api/user.rs +++ b/crates/forgust-server/src/api/user.rs @@ -173,6 +173,15 @@ pub struct AddKey { pub title: String, pub key: String, pub expires_at: Option>, + /// What the key is for; default: both. + pub usage_type: Option, +} + +/// The comment of an OpenSSH public key line, all of it (`ssh-ed25519 AAAA… work laptop` +/// → `work laptop`): the default title, as the profile page fills it in. +fn key_comment(line: &str) -> Option { + let comment = line.split_whitespace().skip(2).collect::>().join(" "); + (!comment.is_empty()).then_some(comment) } #[utoipa::path(get, path = "/api/v1/user/keys", responses((status = 200, body = [SshKey])), tag = "user")] @@ -184,12 +193,13 @@ pub async fn add_key(State(state): State, SignedIn(u, p): SignedIn, Js // a key outlives any token: a leaked token must not be able to plant one require_interactive(&u, &p)?; require_confirmed(&state, &u)?; + let usage = req.usage_type.unwrap_or(forgust_db::ssh_keys::KeyUsage::AuthAndSigning); let (key, fp) = parse_public_key(&req.key)?; - let title = if req.title.trim().is_empty() { req.key.split_whitespace().nth(2).unwrap_or("key").to_string() } else { req.title }; - let id = forgust_db::ssh_keys::insert(&state.db, "user", Some(u.id), &title, &key, &fp, req.expires_at) + let title = if req.title.trim().is_empty() { key_comment(&req.key).unwrap_or_else(|| "key".into()) } else { req.title }; + let id = forgust_db::ssh_keys::insert(&state.db, "user", Some(u.id), &title, &key, &fp, req.expires_at, usage) .await .map_err(|e| if forgust_db::is_unique_violation(&e) { ApiError::conflict("this key is already in use") } else { e.into() })?; - services::audit(&state, &p, "ssh_key_added", Some(("user", u.id)), json!({ "fingerprint": fp }), None).await; + services::audit(&state, &p, "ssh_key_added", Some(("user", u.id)), json!({ "fingerprint": fp, "usage_type": usage }), None).await; let keys = forgust_db::ssh_keys::list_for_user(&state.db, u.id).await?; Ok(Json(keys.into_iter().find(|k| k.id == id).ok_or(ApiError::NotFound)?)) } diff --git a/crates/forgust-server/src/services/push_rules.rs b/crates/forgust-server/src/services/push_rules.rs index 6b989ba..2369ad6 100644 --- a/crates/forgust-server/src/services/push_rules.rs +++ b/crates/forgust-server/src/services/push_rules.rs @@ -143,8 +143,15 @@ pub async fn check( match crate::services::signatures::verify(state, &raw).await { Some(v) if v.verified() => {} Some(v) => { + let fix = match v.verification_status.as_str() { + "unknown_key" => { + "add the key to your profile: an SSH key for signing (or for authentication and signing), or a GPG key" + } + "unverified" => "commit with an email address confirmed on your profile", + _ => "sign it again with a key on your profile", + }; return Ok(Some(format!( - "Commit {short}: its signature is not verified ({}). Only verified signed commits can be pushed: add your key to your profile and commit with your confirmed email.", + "Commit {short}: its signature is not verified ({}). Only verified signed commits can be pushed: {fix}.", v.verification_status.replace('_', " ") ))); } diff --git a/crates/forgust-server/src/services/signatures.rs b/crates/forgust-server/src/services/signatures.rs index 897f910..1a33a2f 100644 --- a/crates/forgust-server/src/services/signatures.rs +++ b/crates/forgust-server/src/services/signatures.rs @@ -1,6 +1,6 @@ //! Signed commits: the OpenPGP or SSH signature in a commit's //! `gpgsig` header, checked against the keys users registered (GPG keys, and -//! their SSH keys, which sign as well as authenticate, as in GitLab). +//! their SSH keys whose `usage_type` includes signing, as in GitLab). //! //! A commit is **verified** when the signature is good, the key belongs to a //! user, and the committer email is that user's confirmed address (and, for @@ -19,7 +19,7 @@ pub struct Verification { pub signature_type: String, /// `verified`; `unverified` (good signature, but the committer email is not /// the key owner's confirmed address); `unknown_key` (no user registered - /// the key); `bad_signature`; `unsupported` (X.509). + /// the key for signing); `bad_signature`; `unsupported` (X.509). pub verification_status: String, /// The key's fingerprint (OpenPGP: primary key, hex; SSH: `SHA256:...`). pub key_fingerprint: Option, @@ -150,7 +150,7 @@ async fn verify_ssh(state: &AppState, s: &Signed, v: &mut Verification) { if key.verify("git", &s.payload, &sig).is_err() { return; } - let row = forgust_db::ssh_keys::find_by_fingerprint(&state.db, &fp).await.ok().flatten().filter(|k| k.kind == "user"); + let row = forgust_db::ssh_keys::find_signing_key(&state.db, &fp).await.ok().flatten().filter(|k| k.for_signing()); let Some(user_id) = row.and_then(|k| k.user_id) else { v.verification_status = "unknown_key".into(); return; diff --git a/crates/forgust-server/src/ssh.rs b/crates/forgust-server/src/ssh.rs index a769bb6..2346b21 100644 --- a/crates/forgust-server/src/ssh.rs +++ b/crates/forgust-server/src/ssh.rs @@ -295,8 +295,18 @@ impl Conn { impl russh::server::Handler for Conn { type Error = anyhow::Error; - async fn auth_publickey_offered(&mut self, user: &str, _offer: PublicKeyOffer<'_>) -> Result { - Ok(if user == "git" && !self.over_limit { Auth::Accept } else { reject() }) + async fn auth_publickey_offered(&mut self, user: &str, offer: PublicKeyOffer<'_>) -> Result { + if user != "git" || self.over_limit { + return Ok(reject()); + } + // a key for signing only is turned down before the client signs with it + // (a security key would ask for a touch for nothing); any other goes on + let fp = offer.public_key.fingerprint(HashAlg::Sha256).to_string(); + if forgust_db::ssh_keys::find_by_fingerprint(&self.state.db, &fp).await?.is_some_and(|k| !k.for_auth()) { + tracing::debug!(peer = ?self.peer, %fp, "SSH key is for signing only"); + return Ok(reject()); + } + Ok(Auth::Accept) } async fn auth_publickey(&mut self, user: &str, verified: VerifiedPublicKey<'_>) -> Result { @@ -309,6 +319,10 @@ impl russh::server::Handler for Conn { tracing::debug!(peer = ?self.peer, %fp, "unknown SSH key"); return Ok(reject()); }; + if !k.for_auth() { + tracing::debug!(peer = ?self.peer, %fp, "SSH key is for signing only"); + return Ok(reject()); + } forgust_db::ssh_keys::touch(&self.state.db, k.id).await?; self.principal = match (k.kind.as_str(), k.user_id) { ("user", Some(uid)) => match forgust_db::users::find(&self.state.db, uid).await? { diff --git a/crates/forgust-server/tests/m1_repos.rs b/crates/forgust-server/tests/m1_repos.rs index bb8884b..3de29b8 100644 --- a/crates/forgust-server/tests/m1_repos.rs +++ b/crates/forgust-server/tests/m1_repos.rs @@ -263,7 +263,8 @@ async fn git_over_ssh_with_user_and_deploy_keys(pool: sqlx::PgPool) { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap(); let public = key.public_key().to_openssh().unwrap(); - owner.ok_post("/api/v1/user/keys", json!({ "title": "laptop", "key": public })).await; + let added = owner.ok_post("/api/v1/user/keys", json!({ "title": "laptop", "key": public })).await; + assert_eq!(added["usage_type"], "auth_and_signing", "keys sign in and sign by default"); // the same key cannot be added twice assert_eq!(owner.post("/api/v1/user/keys", json!({ "title": "again", "key": public })).await.0, 409); @@ -294,6 +295,31 @@ async fn git_over_ssh_with_user_and_deploy_keys(pool: sqlx::PgPool) { must(git_env(&work, &["ls-remote", &url], &denv).await); let push = git_env(&repo, &["push", &url, "main:deploy"], &denv).await; assert!(!push.ok && push.stderr.contains("not allowed to push"), "{}", push.stderr); + + // a key added for signing only cannot sign in over SSH; one added for + // authentication, used the same way, can + let new_key = |file: &str| { + let key = test_key(); + let path = work.join(file); + key.write_openssh_file(&path, russh::keys::ssh_key::LineEnding::LF).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let cmd = ssh_cmd.replace(key_path.to_str().unwrap(), path.to_str().unwrap()); + assert_ne!(cmd, ssh_cmd, "the command uses the new key"); + (key.public_key().to_openssh().unwrap(), cmd) + }; + let (spublic, scmd) = new_key("signing_key"); + let (s, _) = owner.post("/api/v1/user/keys", json!({ "title": "x", "key": spublic, "usage_type": "push" })).await; + assert_eq!(s, 422, "an unknown usage type is refused"); + let added = owner.ok_post("/api/v1/user/keys", json!({ "title": "signing", "key": spublic, "usage_type": "signing" })).await; + assert_eq!(added["usage_type"], "signing"); + let out = git_env(&work, &["ls-remote", &url], &[("GIT_SSH_COMMAND", scmd.as_str())]).await; + assert!(!out.ok && out.stderr.contains("Permission denied (publickey)"), "a signing key must not sign in: {}", out.stderr); + let (apublic, acmd) = new_key("auth_key"); + // no title: the key's whole comment is + let added = + owner.ok_post("/api/v1/user/keys", json!({ "title": "", "key": format!("{apublic} work laptop"), "usage_type": "auth" })).await; + assert_eq!(added["title"], "work laptop"); + must(git_env(&work, &["ls-remote", &url], &[("GIT_SSH_COMMAND", acmd.as_str())]).await); } /// M0 spike: jobs are enqueued inside our own transaction (ADR-0004). diff --git a/crates/forgust-server/tests/m8_review.rs b/crates/forgust-server/tests/m8_review.rs index baeba71..7fd5fa5 100644 --- a/crates/forgust-server/tests/m8_review.rs +++ b/crates/forgust-server/tests/m8_review.rs @@ -136,13 +136,22 @@ async fn ssh_signed_commits_are_verified_and_can_be_required(pool: sqlx::PgPool) let (s, v) = signature(&owner, project, &sha).await; assert_eq!(s, 200, "{v}"); assert_eq!((v["signature_type"].as_str(), v["verification_status"].as_str()), (Some("SSH"), Some("unknown_key")), "{v}"); - // once olivia adds the key, the same commit is verified - owner.ok_post("/api/v1/user/keys", json!({ "title": "laptop", "key": public })).await; + // added for authentication only, the key does not verify commits + let auth_only = owner.ok_post("/api/v1/user/keys", json!({ "title": "laptop", "key": public, "usage_type": "auth" })).await; + assert_eq!(signature(&owner, project, &sha).await.1["verification_status"], "unknown_key"); + assert_eq!(owner.delete(&format!("/api/v1/user/keys/{}", auth_only["id"])).await.0, 200); + // once olivia adds it as a signing key, the same commit is verified + owner.ok_post("/api/v1/user/keys", json!({ "title": "laptop", "key": public, "usage_type": "signing" })).await; let (_, v) = signature(&owner, project, &sha).await; assert_eq!((v["verification_status"].as_str(), v["signer"].as_str()), (Some("verified"), Some("olivia")), "{v}"); let batch = owner.ok_post(&format!("/api/v1/projects/{project}/repository/signatures"), json!({ "shas": [sha, head] })).await; assert_eq!(batch.as_object().unwrap().len(), 1, "unsigned commits are left out: {batch}"); assert_eq!(batch[&sha]["verification_status"], "verified"); + // once the key expires, what it signed stays verified + let expires = |sql: &'static str| sqlx::query(sql).execute(&srv.state.db); + expires("UPDATE ssh_keys SET expires_at = now() - interval '1 day' WHERE title = 'laptop'").await.unwrap(); + assert_eq!(signature(&owner, project, &sha).await.1["verification_status"], "verified"); + expires("UPDATE ssh_keys SET expires_at = NULL WHERE title = 'laptop'").await.unwrap(); // required: unsigned commits are refused, verified ones pass assert_eq!(owner.put(&format!("/api/v1/projects/{project}/push_rule"), json!({ "reject_unsigned_commits": true })).await.0, 200); @@ -163,6 +172,22 @@ async fn ssh_signed_commits_are_verified_and_can_be_required(pool: sqlx::PgPool) must(git_env(&work, &["commit", "-q", "--allow-empty", "-S", "-m", "other email"], &env).await); let out = push(&srv, &work, "olivia", "main").await; assert!(!out.ok && out.stderr.contains("not verified (unverified)"), "{}", out.stderr); + assert!(out.stderr.contains("commit with an email address confirmed on your profile"), "{}", out.stderr); + must(git(&work, &["reset", "-q", "--hard", "HEAD~1"]).await); + // signed with a key not added for signing: the refusal says how to add it + let (other, _) = ssh_signing_key(&srv.work_dir("other-keys")); + let mut env = OLIVIA.to_vec(); + env.extend([ + ("GIT_CONFIG_COUNT", "2"), + ("GIT_CONFIG_KEY_0", "gpg.format"), + ("GIT_CONFIG_VALUE_0", "ssh"), + ("GIT_CONFIG_KEY_1", "user.signingkey"), + ]); + env.push(("GIT_CONFIG_VALUE_1", Box::leak(other.into_boxed_str()))); + must(git_env(&work, &["commit", "-q", "--allow-empty", "-S", "-m", "unknown key"], &env).await); + let out = push(&srv, &work, "olivia", "main").await; + assert!(!out.ok && out.stderr.contains("not verified (unknown key)"), "{}", out.stderr); + assert!(out.stderr.contains("an SSH key for signing (or for authentication and signing)"), "{}", out.stderr); must(git(&work, &["reset", "-q", "--hard", "HEAD~1"]).await); signed("feat: signed again").await; must(push(&srv, &work, "olivia", "main").await); diff --git a/web/openapi.json b/web/openapi.json index e990c43..5afba33 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -4447,6 +4447,17 @@ }, "title": { "type": "string" + }, + "usage_type": { + "oneOf": [ + { + "$ref": "#/components/schemas/KeyUsage", + "description": "What the key is for; default: both." + }, + { + "type": "null" + } + ] } } }, @@ -8907,6 +8918,15 @@ } } }, + "KeyUsage": { + "type": "string", + "description": "What an SSH key is for, as GitLab's `usage_type`: signing in over SSH,\nverifying the commits its owner signs, or both. Deploy keys only sign in\n(the table's CHECK holds them to it).", + "enum": [ + "auth", + "signing", + "auth_and_signing" + ] + }, "KeyValue": { "type": "object", "required": [ @@ -13442,7 +13462,8 @@ "title", "public_key", "fingerprint", - "created_at" + "created_at", + "usage_type" ], "properties": { "created_at": { @@ -13479,6 +13500,9 @@ "title": { "type": "string" }, + "usage_type": { + "$ref": "#/components/schemas/KeyUsage" + }, "user_id": { "type": [ "integer", @@ -14779,7 +14803,7 @@ }, "verification_status": { "type": "string", - "description": "`verified`; `unverified` (good signature, but the committer email is not\nthe key owner's confirmed address); `unknown_key` (no user registered\nthe key); `bad_signature`; `unsupported` (X.509)." + "description": "`verified`; `unverified` (good signature, but the committer email is not\nthe key owner's confirmed address); `unknown_key` (no user registered\nthe key for signing); `bad_signature`; `unsupported` (X.509)." } } }, diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index e5acd10..932439f 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -2436,6 +2436,7 @@ export interface components { expires_at?: string | null; key: string; title: string; + usage_type?: components["schemas"]["KeyUsage"] | null; }; AddMember: { access_level: string; @@ -3777,6 +3778,13 @@ export interface components { failed: components["schemas"]["FailedJob"][]; queues: components["schemas"]["TaskQueue"][]; }; + /** + * @description What an SSH key is for, as GitLab's `usage_type`: signing in over SSH, + * verifying the commits its owner signs, or both. Deploy keys only sign in + * (the table's CHECK holds them to it). + * @enum {string} + */ + KeyUsage: "auth" | "signing" | "auth_and_signing"; KeyValue: { key: string; value: string; @@ -5218,6 +5226,7 @@ export interface components { last_used_at?: string | null; public_key: string; title: string; + usage_type: components["schemas"]["KeyUsage"]; /** Format: int64 */ user_id?: number | null; }; @@ -5670,7 +5679,7 @@ export interface components { /** * @description `verified`; `unverified` (good signature, but the committer email is not * the key owner's confirmed address); `unknown_key` (no user registered - * the key); `bad_signature`; `unsupported` (X.509). + * the key for signing); `bad_signature`; `unsupported` (X.509). */ verification_status: string; }; diff --git a/web/src/components/HoverCards.tsx b/web/src/components/HoverCards.tsx index e70d3de..91040b1 100644 --- a/web/src/components/HoverCards.tsx +++ b/web/src/components/HoverCards.tsx @@ -1,16 +1,22 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, CalendarDays, CircleDot, GitBranch, GitCommitHorizontal, GitMerge, Lock, Tag, Timer } from "lucide-react"; +import { ArrowRight, CalendarDays, CircleDot, GitBranch, GitCommitHorizontal, GitMerge, Lock, ShieldAlert, ShieldCheck, Tag, Timer } from "lucide-react"; import { api, timeAgo, type S } from "../api/client"; import { fetchers, keys } from "../prefetch"; import { Avatar } from "./ui"; import { LabelList } from "./labels"; import { StatusIcon, duration, statusLabel } from "../pages/project/ci/Status"; import { SOURCE } from "../pages/project/ci/Pipelines"; +import { SIGNATURE_TEXT } from "../pages/project/Signatures"; type Item = { kind: "mr" | "issue" | "pipeline" | "job"; path: string; iid: number }; -type Target = Item | { kind: "user"; username: string } | { kind: "commit"; path: string; sha: string } | { kind: "branch"; path: string; name: string }; +type Target = + | Item + | { kind: "user"; username: string } + | { kind: "commit"; path: string; sha: string } + | { kind: "branch"; path: string; name: string } + | { kind: "signature"; v: S["Verification"] }; const KINDS: Record = { merge_requests: "mr", issues: "issue", pipelines: "pipeline", jobs: "job" }; @@ -47,8 +53,20 @@ function targetOf(a: HTMLAnchorElement): Target | null { return null; } -/** Links that may get a card (targetOf decides), and anything with a tooltip. */ -const SELECTOR = "a[href]"; +/** Links that may get a card (targetOf decides), and signature badges (their check in `data-signature`). */ +const SELECTOR = "a[href], [data-signature]"; + +/** What an element shows a card for: a link's target, or a signature badge's check. */ +function cardOf(el: HTMLElement): Target | null { + if (el.dataset.signature) { + try { + return { kind: "signature", v: JSON.parse(el.dataset.signature) }; + } catch { + return null; + } + } + return el instanceof HTMLAnchorElement ? targetOf(el) : null; +} const SHOW_MS = 350; const HIDE_MS = 200; const TIP_MS = 300; @@ -131,16 +149,16 @@ function useTips() { */ export function HoverCards() { const tip = useTips(); - const [card, setCard] = useState<{ target: Target; rect: DOMRect; anchor: HTMLAnchorElement } | null>(null); + const [card, setCard] = useState<{ target: Target; rect: DOMRect; anchor: HTMLElement } | null>(null); const showT = useRef(undefined); const hideT = useRef(undefined); const over = useRef(false); useEffect(() => { const enter = (e: Event) => { - const a = (e.target as Element | null)?.closest?.(SELECTOR) as HTMLAnchorElement | null; + const a = (e.target as Element | null)?.closest?.(SELECTOR) as HTMLElement | null; if (!a) return; - const target = targetOf(a); + const target = cardOf(a); if (!target) return; clearTimeout(hideT.current); clearTimeout(showT.current); @@ -211,7 +229,9 @@ export function HoverCards() { setCard(null); }} > - {card.target.kind === "user" ? ( + {card.target.kind === "signature" ? ( + + ) : card.target.kind === "user" ? ( ) : card.target.kind === "commit" ? ( @@ -394,6 +414,29 @@ function ItemCard({ t }: { t: { kind: "mr" | "issue"; path: string; iid: number ); } +/** A commit's signature: verified or not and why, whose key, and which. */ +function SignatureCard({ v }: { v: S["Verification"] }) { + const ok = v.verification_status === "verified"; + return ( +
+
+ + {ok ? : } {ok ? "Verified" : "Unverified"} + + {v.signature_type} signature +
+
{SIGNATURE_TEXT[v.verification_status]}
+ {v.signer && ( +
+ + Key of @{v.signer} +
+ )} + {v.key_fingerprint &&
{v.key_fingerprint}
} +
+ ); +} + function UserCard({ username }: { username: string }) { const q = useQuery({ queryKey: ["profile", username], queryFn: () => api.get(`/users/${encodeURIComponent(username)}`), staleTime: 60_000 }); if (q.error) return
Not available.
; diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 37a7662..aadc4c4 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -43,7 +43,7 @@ export function Badge({ children, tone }: { children: ReactNode; tone?: "accent" return {children}; } -export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { +export function Field({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) { return (