| 1 | //! Deploy keys, and who an SSH key belongs to. |
| 2 | //! |
| 3 | //! A deploy key is an SSH key one repository's admins add so that a |
| 4 | //! machine can clone it, or push to it when they allowed write access. The |
| 5 | //! rules are `g1t_contracts::deploy_keys`; this keeps the keys, beside |
| 6 | //! people's own (`ssh_keys`, lib.rs). A public key is registered once |
| 7 | //! across both tables: the services check, and triggers (migration 0035) |
| 8 | //! refuse a second insert that slips past the check. |
| 9 | //! |
| 10 | //! Keys are kept by repository id, so a rename or a transfer keeps them; |
| 11 | //! the path, and the workspace a key acts as, are looked up each time it |
| 12 | //! is used. A deleted repository's keys resolve to no one while it is |
| 13 | //! deleted, and go with it when it is purged (`forget_deploy_keys`, called |
| 14 | //! with `forget_repo_access`). |
| 15 | //! |
| 16 | //! **Last used.** Both kinds of key record when they last signed in, at |
| 17 | //! most once every 5 minutes, as access tokens do (tokens.rs), and only once |
| 18 | //! the client proved it holds the private key. |
| 19 | |
| 20 | use g1t_contracts::audit::Surface; |
| 21 | use g1t_contracts::deploy_keys::{ |
| 22 | self, AddDeployKeyArgs, DeployKey, DeployKeyArgs, DeployKeysArgs, KEY_IN_USE, MAX_PER_REPO, RemoveDeployKeyArgs, |
| 23 | SshKeyArgs, |
| 24 | }; |
| 25 | use g1t_contracts::repos::{PathByIdArgs, Repo, RepoPath}; |
| 26 | use g1t_contracts::time::rfc3339; |
| 27 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; |
| 28 | use g1t_kit::now_ms; |
| 29 | use serde::Deserialize; |
| 30 | use worker::Result; |
| 31 | use worker::wasm_bindgen::JsValue; |
| 32 | |
| 33 | use crate::{Identity, crypto}; |
| 34 | |
| 35 | /// How stale a key's last-used time may get before it is written again. |
| 36 | const LAST_USED_RESOLUTION_MS: u64 = 5 * 60 * 1000; |
| 37 | const NOT_FOUND: &str = "Repository not found."; |
| 38 | const NO_SUCH_KEY: &str = "There is no deploy key with that id on this repository."; |
| 39 | |
| 40 | const COLUMNS: &str = "dk.id, dk.title, dk.public_key, dk.fingerprint, dk.read_only, dk.created_at, dk.last_used_at, |
| 41 | COALESCE(u.username, w.slug) AS created_by |
| 42 | FROM deploy_keys dk |
| 43 | LEFT JOIN users u ON u.id = dk.created_by |
| 44 | LEFT JOIN workspaces w ON w.id = dk.created_by"; |
| 45 | |
| 46 | #[derive(Deserialize)] |
| 47 | struct Row { |
| 48 | id: String, |
| 49 | title: String, |
| 50 | public_key: String, |
| 51 | fingerprint: String, |
| 52 | read_only: u8, |
| 53 | created_at: String, |
| 54 | last_used_at: Option<String>, |
| 55 | created_by: Option<String>, |
| 56 | } |
| 57 | |
| 58 | impl From<Row> for DeployKey { |
| 59 | fn from(row: Row) -> Self { |
| 60 | DeployKey { |
| 61 | id: row.id, |
| 62 | title: row.title, |
| 63 | key: row.public_key, |
| 64 | fingerprint: row.fingerprint, |
| 65 | read_only: row.read_only != 0, |
| 66 | created_at: row.created_at, |
| 67 | created_by: row.created_by, |
| 68 | last_used_at: row.last_used_at, |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// The title a key is given: what was typed, else the key's comment, else |
| 74 | /// `fallback`. |
| 75 | pub fn title_for(typed: &str, comment: &str, fallback: &str) -> String { |
| 76 | [typed.trim(), comment.trim(), fallback] |
| 77 | .into_iter() |
| 78 | .find(|candidate| !candidate.is_empty()) |
| 79 | .unwrap_or_default() |
| 80 | .chars() |
| 81 | .take(100) |
| 82 | .collect() |
| 83 | } |
| 84 | |
| 85 | /// Whether a last-used time should be written now. |
| 86 | fn due(last: Option<&str>) -> bool { |
| 87 | deploy_keys::note_use_due(last, &rfc3339(now_ms().saturating_sub(LAST_USED_RESOLUTION_MS))) |
| 88 | } |
| 89 | |
| 90 | impl Identity { |
| 91 | /// The repository at `path`, if `viewer` may see and change its deploy |
| 92 | /// keys, with the id of its workspace. |
| 93 | async fn keys_of(&self, viewer: &Viewer, path: &RepoPath) -> Result<Outcome<(Repo, String)>> { |
| 94 | let Some(repo) = self.repo_for(path, viewer).await? else { |
| 95 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND)); |
| 96 | }; |
| 97 | if let Some(why) = deploy_keys::refusal(viewer.as_ref(), (&repo).into(), &format!("{}/{}", repo.namespace, repo.name)) { |
| 98 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); |
| 99 | } |
| 100 | let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else { |
| 101 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND)); |
| 102 | }; |
| 103 | Ok(Outcome::Ok((repo, workspace_id))) |
| 104 | } |
| 105 | |
| 106 | async fn deploy_key_row(&self, repo_id: &str, id: &str) -> Result<Option<DeployKey>> { |
| 107 | Ok(self |
| 108 | .db |
| 109 | .prepare(format!("SELECT {COLUMNS} WHERE dk.repo_id = ? AND dk.id = ?")) |
| 110 | .bind(&[repo_id.into(), id.trim().into()])? |
| 111 | .first::<Row>(None) |
| 112 | .await? |
| 113 | .map(DeployKey::from)) |
| 114 | } |
| 115 | |
| 116 | /// Whether any account's SSH key, or any repository's deploy key, has |
| 117 | /// this fingerprint. |
| 118 | pub(crate) async fn key_in_use(&self, fingerprint: &str) -> Result<bool> { |
| 119 | Ok(self |
| 120 | .db |
| 121 | .prepare( |
| 122 | "SELECT fingerprint FROM ssh_keys WHERE fingerprint = ?1 |
| 123 | UNION ALL SELECT fingerprint FROM deploy_keys WHERE fingerprint = ?1 LIMIT 1", |
| 124 | ) |
| 125 | .bind(&[fingerprint.into()])? |
| 126 | .first::<serde_json::Value>(None) |
| 127 | .await? |
| 128 | .is_some()) |
| 129 | } |
| 130 | |
| 131 | pub async fn list_deploy_keys(&self, a: DeployKeysArgs) -> Result<Outcome<Vec<DeployKey>>> { |
| 132 | let (repo, _) = match self.keys_of(&a.viewer, &a.path).await? { |
| 133 | Outcome::Ok(found) => found, |
| 134 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 135 | }; |
| 136 | let rows = self |
| 137 | .db |
| 138 | .prepare(format!("SELECT {COLUMNS} WHERE dk.repo_id = ? ORDER BY dk.created_at, dk.id LIMIT {MAX_PER_REPO}")) |
| 139 | .bind(&[repo.id.as_str().into()])? |
| 140 | .all() |
| 141 | .await? |
| 142 | .results::<Row>()?; |
| 143 | Ok(Outcome::Ok(rows.into_iter().map(DeployKey::from).collect())) |
| 144 | } |
| 145 | |
| 146 | pub async fn get_deploy_key(&self, a: DeployKeyArgs) -> Result<Outcome<DeployKey>> { |
| 147 | let (repo, _) = match self.keys_of(&a.viewer, &a.path).await? { |
| 148 | Outcome::Ok(found) => found, |
| 149 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 150 | }; |
| 151 | Ok(match self.deploy_key_row(&repo.id, &a.id).await? { |
| 152 | Some(key) => Outcome::Ok(key), |
| 153 | None => Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY), |
| 154 | }) |
| 155 | } |
| 156 | |
| 157 | pub async fn add_deploy_key(&self, a: AddDeployKeyArgs) -> Result<Outcome<DeployKey>> { |
| 158 | let actor = Some(a.actor.clone()); |
| 159 | let (repo, workspace_id) = match self.keys_of(&actor, &a.path).await? { |
| 160 | Outcome::Ok(found) => found, |
| 161 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 162 | }; |
| 163 | if !a.actor.verified { |
| 164 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before adding deploy keys.")); |
| 165 | } |
| 166 | let Some(key) = crypto::parse_ssh_key(&a.key) else { |
| 167 | return Ok(Outcome::fail( |
| 168 | FailureCode::Invalid, |
| 169 | "That is not a valid OpenSSH public key. Paste one line, such as the contents of id_ed25519.pub.", |
| 170 | )); |
| 171 | }; |
| 172 | if self.key_in_use(&key.fingerprint).await? { |
| 173 | return Ok(Outcome::fail(FailureCode::Conflict, KEY_IN_USE)); |
| 174 | } |
| 175 | #[derive(Deserialize)] |
| 176 | struct Count { |
| 177 | count: u32, |
| 178 | } |
| 179 | let count = self |
| 180 | .db |
| 181 | .prepare("SELECT count(*) AS count FROM deploy_keys WHERE repo_id = ?") |
| 182 | .bind(&[repo.id.as_str().into()])? |
| 183 | .first::<Count>(None) |
| 184 | .await? |
| 185 | .map_or(0, |row| row.count); |
| 186 | if count as usize >= MAX_PER_REPO { |
| 187 | return Ok(Outcome::fail( |
| 188 | FailureCode::Conflict, |
| 189 | format!("A repository can have at most {MAX_PER_REPO} deploy keys. Delete one first."), |
| 190 | )); |
| 191 | } |
| 192 | let now = now_ms(); |
| 193 | let id = new_id("dk", now); |
| 194 | let title = title_for(&a.title, &key.comment, "Deploy key"); |
| 195 | let inserted = self |
| 196 | .db |
| 197 | .prepare( |
| 198 | "INSERT INTO deploy_keys (id, repo_id, workspace_id, title, public_key, fingerprint, read_only, created_by, created_at) |
| 199 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 200 | ) |
| 201 | .bind(&[ |
| 202 | id.as_str().into(), |
| 203 | repo.id.as_str().into(), |
| 204 | workspace_id.as_str().into(), |
| 205 | title.as_str().into(), |
| 206 | key.public_key.as_str().into(), |
| 207 | key.fingerprint.as_str().into(), |
| 208 | JsValue::from(u8::from(a.read_only)), |
| 209 | a.actor.id.as_str().into(), |
| 210 | rfc3339(now).into(), |
| 211 | ])? |
| 212 | .run() |
| 213 | .await; |
| 214 | // Added at the same moment elsewhere: the trigger or the unique |
| 215 | // index refused it. |
| 216 | if let Err(error) = inserted { |
| 217 | if self.key_in_use(&key.fingerprint).await? { |
| 218 | return Ok(Outcome::fail(FailureCode::Conflict, KEY_IN_USE)); |
| 219 | } |
| 220 | return Err(error); |
| 221 | } |
| 222 | let access = if a.read_only { "read-only" } else { "read and write" }; |
| 223 | self.audit( |
| 224 | &a.actor, |
| 225 | "repo.deploy_key_added", |
| 226 | (&repo).into(), |
| 227 | a.surface.unwrap_or(Surface::Web), |
| 228 | format!("Added the deploy key \"{title}\" ({}, {access})", key.fingerprint), |
| 229 | ) |
| 230 | .await; |
| 231 | Ok(match self.deploy_key_row(&repo.id, &id).await? { |
| 232 | Some(key) => Outcome::Ok(key), |
| 233 | None => Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY), |
| 234 | }) |
| 235 | } |
| 236 | |
| 237 | pub async fn remove_deploy_key(&self, a: RemoveDeployKeyArgs) -> Result<Outcome<bool>> { |
| 238 | let actor = Some(a.actor.clone()); |
| 239 | let (repo, _) = match self.keys_of(&actor, &a.path).await? { |
| 240 | Outcome::Ok(found) => found, |
| 241 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 242 | }; |
| 243 | let Some(key) = self.deploy_key_row(&repo.id, &a.id).await? else { |
| 244 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY)); |
| 245 | }; |
| 246 | self.db |
| 247 | .prepare("DELETE FROM deploy_keys WHERE id = ? AND repo_id = ?") |
| 248 | .bind(&[key.id.as_str().into(), repo.id.as_str().into()])? |
| 249 | .run() |
| 250 | .await?; |
| 251 | self.audit( |
| 252 | &a.actor, |
| 253 | "repo.deploy_key_removed", |
| 254 | (&repo).into(), |
| 255 | a.surface.unwrap_or(Surface::Web), |
| 256 | format!("Removed the deploy key \"{}\" ({})", key.title, key.fingerprint), |
| 257 | ) |
| 258 | .await; |
| 259 | Ok(Outcome::Ok(true)) |
| 260 | } |
| 261 | |
| 262 | /// A purged repository's deploy keys go with it. |
| 263 | pub async fn forget_deploy_keys(&self, repo_id: &str) -> Result<()> { |
| 264 | self.db |
| 265 | .prepare("DELETE FROM deploy_keys WHERE repo_id = ?") |
| 266 | .bind(&[repo_id.into()])? |
| 267 | .run() |
| 268 | .await?; |
| 269 | Ok(()) |
| 270 | } |
| 271 | |
| 272 | /// Who signs in with the SSH key with this fingerprint: the person who |
| 273 | /// registered it, with their workspaces and grants as any credential |
| 274 | /// resolves them; or, for a deploy key, its repository's workspace |
| 275 | /// acting through a token that reaches that repository only |
| 276 | /// (`deploy_keys::principal`). Nobody for an unknown key, or a deploy |
| 277 | /// key whose repository or workspace is deleted. |
| 278 | pub async fn principal_for_ssh_key(&self, a: SshKeyArgs) -> Result<Viewer> { |
| 279 | #[derive(Deserialize)] |
| 280 | struct Person { |
| 281 | key_id: String, |
| 282 | last_used_at: Option<String>, |
| 283 | id: String, |
| 284 | username: String, |
| 285 | verified: u8, |
| 286 | } |
| 287 | let person = self |
| 288 | .db |
| 289 | .prepare( |
| 290 | "SELECT ssh_keys.id AS key_id, ssh_keys.last_used_at, users.id, users.username, |
| 291 | users.email_verified_at IS NOT NULL AS verified |
| 292 | FROM ssh_keys JOIN users ON users.id = ssh_keys.user_id |
| 293 | WHERE ssh_keys.fingerprint = ?", |
| 294 | ) |
| 295 | .bind(&[a.fingerprint.as_str().into()])? |
| 296 | .first::<Person>(None) |
| 297 | .await?; |
| 298 | if let Some(person) = person { |
| 299 | if a.used && due(person.last_used_at.as_deref()) { |
| 300 | self.note_key_use("ssh_keys", &person.key_id).await?; |
| 301 | } |
| 302 | let user = User { |
| 303 | id: person.id, |
| 304 | username: person.username, |
| 305 | verified: person.verified != 0, |
| 306 | ..User::default() |
| 307 | }; |
| 308 | return self.with_workspaces(Some(user)).await; |
| 309 | } |
| 310 | |
| 311 | #[derive(Deserialize)] |
| 312 | struct Key { |
| 313 | id: String, |
| 314 | title: String, |
| 315 | repo_id: String, |
| 316 | read_only: u8, |
| 317 | last_used_at: Option<String>, |
| 318 | } |
| 319 | let Some(key) = self |
| 320 | .db |
| 321 | .prepare("SELECT id, title, repo_id, read_only, last_used_at FROM deploy_keys WHERE fingerprint = ?") |
| 322 | .bind(&[a.fingerprint.as_str().into()])? |
| 323 | .first::<Key>(None) |
| 324 | .await? |
| 325 | else { |
| 326 | return Ok(None); |
| 327 | }; |
| 328 | // Where the repository is now; none while it is deleted. |
| 329 | let path: Option<RepoPath> = |
| 330 | g1t_kit::call(&self.env.service("REPOS")?, "path_by_id", &PathByIdArgs { id: key.repo_id.clone() }).await?; |
| 331 | let Some(path) = path else { |
| 332 | return Ok(None); |
| 333 | }; |
| 334 | let Some(workspace_id) = self.workspace_id_of(&path.namespace).await? else { |
| 335 | return Ok(None); |
| 336 | }; |
| 337 | let Some(workspace) = self.workspace_principal(&workspace_id).await? else { |
| 338 | return Ok(None); |
| 339 | }; |
| 340 | if a.used && due(key.last_used_at.as_deref()) { |
| 341 | self.note_key_use("deploy_keys", &key.id).await?; |
| 342 | } |
| 343 | let repo = format!("{}/{}", path.namespace, path.name); |
| 344 | let access = deploy_keys::access(&key.id, &key.title, &repo, key.read_only != 0); |
| 345 | Ok(Some(deploy_keys::principal(&workspace.id, &workspace.username, access))) |
| 346 | } |
| 347 | |
| 348 | async fn note_key_use(&self, table: &str, id: &str) -> Result<()> { |
| 349 | self.db |
| 350 | .prepare(format!("UPDATE {table} SET last_used_at = ? WHERE id = ?")) |
| 351 | .bind(&[rfc3339(now_ms()).into(), id.into()])? |
| 352 | .run() |
| 353 | .await?; |
| 354 | Ok(()) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | #[cfg(test)] |
| 359 | mod tests { |
| 360 | use super::*; |
| 361 | |
| 362 | #[test] |
| 363 | fn a_key_is_titled_by_what_was_typed_then_its_comment() { |
| 364 | assert_eq!(title_for(" CI ", "ana@laptop", "Deploy key"), "CI"); |
| 365 | assert_eq!(title_for("", "ana@laptop", "Deploy key"), "ana@laptop"); |
| 366 | assert_eq!(title_for(" ", " ", "Deploy key"), "Deploy key"); |
| 367 | assert_eq!(title_for(&"x".repeat(300), "", "Deploy key").len(), 100); |
| 368 | } |
| 369 | |
| 370 | #[test] |
| 371 | fn a_key_parsed_for_a_repository_is_stored_without_its_comment() { |
| 372 | let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE deploy@ci"; |
| 373 | let key = crypto::parse_ssh_key(line).unwrap(); |
| 374 | assert_eq!(key.public_key, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE"); |
| 375 | assert!(key.fingerprint.starts_with("SHA256:")); |
| 376 | assert_eq!(title_for("", &key.comment, "Deploy key"), "deploy@ci"); |
| 377 | } |
| 378 | } |