| 1 | //! Repository secrets: the settings UI (which encrypts in the browser) and |
| 2 | //! the JSON API the CLI uses to read envelopes, store them, and unlock a |
| 3 | //! repository for CI. |
| 4 | //! |
| 5 | //! Plaintext never reaches these handlers. The browser seals a value to the |
| 6 | //! owner's ssh-ed25519 keys with WebCrypto before posting, and the CLI does the |
| 7 | //! same locally; the server only ever sees `anvil-secret-v1` envelopes. The one |
| 8 | //! exception is [`unlock`], where a client that *has* decrypted the values |
| 9 | //! hands them over to be held in RAM for CI (see [`anvil_core::secrets::Vault`] |
| 10 | //! and `docs/secrets.md`). |
| 11 | |
| 12 | use anvil_core::{ |
| 13 | App, |
| 14 | Repository, |
| 15 | User, |
| 16 | access, |
| 17 | repos, |
| 18 | secrets::{ |
| 19 | self, |
| 20 | Envelope, |
| 21 | }, |
| 22 | ssh_keys, |
| 23 | users, |
| 24 | }; |
| 25 | use axum::{ |
| 26 | Json, |
| 27 | Router, |
| 28 | extract::{ |
| 29 | Path, |
| 30 | State, |
| 31 | }, |
| 32 | http::{ |
| 33 | HeaderMap, |
| 34 | StatusCode, |
| 35 | }, |
| 36 | response::{ |
| 37 | IntoResponse, |
| 38 | Redirect, |
| 39 | Response, |
| 40 | }, |
| 41 | routing::{ |
| 42 | get, |
| 43 | post, |
| 44 | }, |
| 45 | }; |
| 46 | use maud::{ |
| 47 | Markup, |
| 48 | PreEscaped, |
| 49 | html, |
| 50 | }; |
| 51 | use serde::{ |
| 52 | Deserialize, |
| 53 | Serialize, |
| 54 | }; |
| 55 | |
| 56 | use crate::{ |
| 57 | auth::{ |
| 58 | Csrf, |
| 59 | CurrentUser, |
| 60 | basic_auth_user, |
| 61 | verify_csrf, |
| 62 | }, |
| 63 | ui::{ |
| 64 | csrf_input, |
| 65 | fmt_relative, |
| 66 | }, |
| 67 | }; |
| 68 | |
| 69 | pub fn routes(router: Router<App>) -> Router<App> { |
| 70 | router |
| 71 | .route( |
| 72 | "/{owner}/{repo}/-/api/secrets", |
| 73 | get(list_secrets).post(put_secret), |
| 74 | ) |
| 75 | .route( |
| 76 | "/{owner}/{repo}/-/api/secrets/{name}", |
| 77 | axum::routing::delete(delete_secret), |
| 78 | ) |
| 79 | .route("/{owner}/{repo}/-/api/secrets/unlock", post(unlock)) |
| 80 | .route("/{owner}/{repo}/-/api/secrets/lock", post(lock)) |
| 81 | // Plain form posts from the settings page (no JSON, no plaintext). |
| 82 | .route("/{owner}/{repo}/-/secrets/{name}/delete", post(ui_delete)) |
| 83 | .route("/{owner}/{repo}/-/secrets/lock", post(ui_lock)) |
| 84 | // User secrets: same shape, no {owner}/{repo} — always the caller's own. |
| 85 | .route( |
| 86 | "/-/api/user/secrets", |
| 87 | get(list_user_secrets).post(put_user_secret), |
| 88 | ) |
| 89 | .route( |
| 90 | "/-/api/user/secrets/{name}", |
| 91 | axum::routing::delete(delete_user_secret), |
| 92 | ) |
| 93 | .route("/-/api/user/secrets/unlock", post(unlock_user)) |
| 94 | .route("/-/api/user/secrets/lock", post(lock_user)) |
| 95 | .route("/-/settings/secrets/{name}/delete", post(ui_delete_user)) |
| 96 | .route("/-/settings/secrets/lock", post(ui_lock_user)) |
| 97 | } |
| 98 | |
| 99 | // --- request plumbing ------------------------------------------------------ |
| 100 | |
| 101 | /// Resolve the repository and check write access, accepting either a signed-in |
| 102 | /// session (with a CSRF token, as the browser sends) or HTTP Basic credentials |
| 103 | /// (as the CLI sends). Browsers never attach Basic credentials on their own, so |
| 104 | /// the Basic path needs no CSRF defence; the session path always does. |
| 105 | async fn authorize( |
| 106 | app: &App, |
| 107 | session_user: Option<User>, |
| 108 | csrf: &Csrf, |
| 109 | headers: &HeaderMap, |
| 110 | owner: &str, |
| 111 | repo: &str, |
| 112 | ) -> Result<Repository, Response> { |
| 113 | let authorization = headers |
| 114 | .get(axum::http::header::AUTHORIZATION) |
| 115 | .and_then(|v| v.to_str().ok()); |
| 116 | let user = match authorization { |
| 117 | Some(header) if header.to_ascii_lowercase().starts_with("basic ") => { |
| 118 | basic_auth_user(app, Some(header)).await |
| 119 | } |
| 120 | _ => { |
| 121 | let submitted = headers |
| 122 | .get("x-csrf-token") |
| 123 | .and_then(|v| v.to_str().ok()) |
| 124 | .unwrap_or_default(); |
| 125 | verify_csrf(csrf, submitted)?; |
| 126 | session_user |
| 127 | } |
| 128 | }; |
| 129 | let Some(user) = user else { |
| 130 | return Err((StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response()); |
| 131 | }; |
| 132 | let meta = resolve(app, owner, repo).await?; |
| 133 | if !access::can_write(&meta, Some(&user)) { |
| 134 | return Err((StatusCode::NOT_FOUND, "no such repository").into_response()); |
| 135 | } |
| 136 | Ok(meta) |
| 137 | } |
| 138 | |
| 139 | async fn resolve(app: &App, owner: &str, repo: &str) -> Result<Repository, Response> { |
| 140 | let user = users::find_by_username(&app.db, owner) |
| 141 | .await |
| 142 | .map_err(server_error)?; |
| 143 | let meta = match user { |
| 144 | Some(u) => repos::find(&app.db, u.id, repo) |
| 145 | .await |
| 146 | .map_err(server_error)?, |
| 147 | None => None, |
| 148 | }; |
| 149 | meta.ok_or_else(|| (StatusCode::NOT_FOUND, "no such repository").into_response()) |
| 150 | } |
| 151 | |
| 152 | fn server_error(e: impl std::fmt::Display) -> Response { |
| 153 | tracing::error!("secrets: {e}"); |
| 154 | (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() |
| 155 | } |
| 156 | |
| 157 | fn bad_request(e: impl std::fmt::Display) -> Response { |
| 158 | (StatusCode::BAD_REQUEST, e.to_string()).into_response() |
| 159 | } |
| 160 | |
| 161 | // --- JSON API -------------------------------------------------------------- |
| 162 | |
| 163 | #[derive(Serialize)] |
| 164 | struct SecretsResponse { |
| 165 | repo: String, |
| 166 | /// Unix time the current unlock expires, or 0 when sealed. |
| 167 | unlocked_until: i64, |
| 168 | /// The ssh-ed25519 keys secrets must be sealed to, i.e. the owner's. |
| 169 | recipients: Vec<RecipientJson>, |
| 170 | secrets: Vec<SecretJson>, |
| 171 | } |
| 172 | |
| 173 | #[derive(Serialize)] |
| 174 | struct RecipientJson { |
| 175 | fingerprint: String, |
| 176 | /// The OpenSSH public-key line, so a client can seal without re-fetching. |
| 177 | key: String, |
| 178 | } |
| 179 | |
| 180 | #[derive(Serialize)] |
| 181 | struct SecretJson { |
| 182 | name: String, |
| 183 | envelope: serde_json::Value, |
| 184 | recipients: Vec<String>, |
| 185 | updated_at: i64, |
| 186 | } |
| 187 | |
| 188 | /// `GET /{owner}/{repo}/-/api/secrets` — the sealed envelopes plus the current |
| 189 | /// recipient set. Readable only by someone who could write them anyway; the |
| 190 | /// envelopes are useless without a private key regardless. |
| 191 | async fn list_secrets( |
| 192 | State(app): State<App>, |
| 193 | CurrentUser(user): CurrentUser, |
| 194 | csrf: Csrf, |
| 195 | Path((owner, repo)): Path<(String, String)>, |
| 196 | headers: HeaderMap, |
| 197 | ) -> Response { |
| 198 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 199 | Ok(m) => m, |
| 200 | Err(resp) => return resp, |
| 201 | }; |
| 202 | let recipients = match recipients_for(&app, &meta).await { |
| 203 | Ok(r) => r, |
| 204 | Err(resp) => return resp, |
| 205 | }; |
| 206 | let stored = match secrets::list(&app.db, meta.id).await { |
| 207 | Ok(s) => s, |
| 208 | Err(e) => return server_error(e).into_response(), |
| 209 | }; |
| 210 | let secrets_json = stored |
| 211 | .into_iter() |
| 212 | .map(|s| SecretJson { |
| 213 | envelope: serde_json::from_str(&s.envelope).unwrap_or(serde_json::Value::Null), |
| 214 | recipients: split_fingerprints(&s.recipients), |
| 215 | name: s.name, |
| 216 | updated_at: s.updated_at, |
| 217 | }) |
| 218 | .collect(); |
| 219 | Json(SecretsResponse { |
| 220 | repo: format!("{owner}/{repo}"), |
| 221 | unlocked_until: app |
| 222 | .vault |
| 223 | .status(meta.id) |
| 224 | .map(|s| s.expires_at) |
| 225 | .unwrap_or_default(), |
| 226 | recipients: recipients |
| 227 | .into_iter() |
| 228 | .map(|(recipient, line)| RecipientJson { |
| 229 | fingerprint: recipient.fingerprint, |
| 230 | key: line, |
| 231 | }) |
| 232 | .collect(), |
| 233 | secrets: secrets_json, |
| 234 | }) |
| 235 | .into_response() |
| 236 | } |
| 237 | |
| 238 | #[derive(Deserialize)] |
| 239 | struct PutSecret { |
| 240 | name: String, |
| 241 | envelope: serde_json::Value, |
| 242 | } |
| 243 | |
| 244 | /// `POST /{owner}/{repo}/-/api/secrets` — store a sealed envelope under a name, |
| 245 | /// replacing any previous value. The body is ciphertext; the server checks only |
| 246 | /// its shape. |
| 247 | async fn put_secret( |
| 248 | State(app): State<App>, |
| 249 | CurrentUser(user): CurrentUser, |
| 250 | csrf: Csrf, |
| 251 | Path((owner, repo)): Path<(String, String)>, |
| 252 | headers: HeaderMap, |
| 253 | Json(body): Json<PutSecret>, |
| 254 | ) -> Response { |
| 255 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 256 | Ok(m) => m, |
| 257 | Err(resp) => return resp, |
| 258 | }; |
| 259 | if !secrets::valid_name(&body.name) { |
| 260 | return bad_request("secret names are A–Z, 0–9 and _, and cannot start with a digit"); |
| 261 | } |
| 262 | let json = match serde_json::to_string(&body.envelope) { |
| 263 | Ok(j) => j, |
| 264 | Err(e) => return bad_request(e), |
| 265 | }; |
| 266 | let envelope = match Envelope::parse(&json) { |
| 267 | Ok(e) => e, |
| 268 | Err(e) => return bad_request(e), |
| 269 | }; |
| 270 | // A secret nobody can open is a footgun, not a feature: require it to be |
| 271 | // sealed to at least one key that is still registered. |
| 272 | let current = match recipients_for(&app, &meta).await { |
| 273 | Ok(r) => r, |
| 274 | Err(resp) => return resp, |
| 275 | }; |
| 276 | let sealed_to = envelope.recipient_fingerprints(); |
| 277 | if !current |
| 278 | .iter() |
| 279 | .any(|(r, _)| sealed_to.contains(&r.fingerprint)) |
| 280 | { |
| 281 | return bad_request("envelope is not sealed to any registered ssh key"); |
| 282 | } |
| 283 | match secrets::put(&app.db, meta.id, &body.name, &envelope).await { |
| 284 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 285 | Err(e) => bad_request(e), |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | /// `DELETE /{owner}/{repo}/-/api/secrets/{name}`. |
| 290 | async fn delete_secret( |
| 291 | State(app): State<App>, |
| 292 | CurrentUser(user): CurrentUser, |
| 293 | csrf: Csrf, |
| 294 | Path((owner, repo, name)): Path<(String, String, String)>, |
| 295 | headers: HeaderMap, |
| 296 | ) -> Response { |
| 297 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 298 | Ok(m) => m, |
| 299 | Err(resp) => return resp, |
| 300 | }; |
| 301 | match secrets::delete(&app.db, meta.id, &name).await { |
| 302 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 303 | Err(e) => server_error(e), |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | #[derive(Deserialize)] |
| 308 | struct UnlockBody { |
| 309 | values: std::collections::BTreeMap<String, String>, |
| 310 | #[serde(default)] |
| 311 | ttl_secs: i64, |
| 312 | } |
| 313 | |
| 314 | #[derive(Serialize)] |
| 315 | struct UnlockResponse { |
| 316 | unlocked_until: i64, |
| 317 | count: usize, |
| 318 | } |
| 319 | |
| 320 | /// `POST /{owner}/{repo}/-/api/secrets/unlock` — hand the server decrypted |
| 321 | /// values to hold in memory for CI until they expire. |
| 322 | /// |
| 323 | /// This is the *only* endpoint that sees plaintext, and the client must have |
| 324 | /// opened the envelopes itself to call it. Nothing is written to disk. |
| 325 | async fn unlock( |
| 326 | State(app): State<App>, |
| 327 | CurrentUser(user): CurrentUser, |
| 328 | csrf: Csrf, |
| 329 | Path((owner, repo)): Path<(String, String)>, |
| 330 | headers: HeaderMap, |
| 331 | Json(body): Json<UnlockBody>, |
| 332 | ) -> Response { |
| 333 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 334 | Ok(m) => m, |
| 335 | Err(resp) => return resp, |
| 336 | }; |
| 337 | for name in body.values.keys() { |
| 338 | if !secrets::valid_name(name) { |
| 339 | return bad_request(format!("invalid secret name `{name}`")); |
| 340 | } |
| 341 | } |
| 342 | let count = body.values.len(); |
| 343 | let ttl = if body.ttl_secs > 0 { |
| 344 | body.ttl_secs |
| 345 | } else { |
| 346 | 8 * 60 * 60 |
| 347 | }; |
| 348 | let unlocked_until = app.vault.unlock(meta.id, body.values, ttl); |
| 349 | tracing::info!("secrets: {owner}/{repo} unlocked with {count} value(s) until {unlocked_until}"); |
| 350 | Json(UnlockResponse { |
| 351 | unlocked_until, |
| 352 | count, |
| 353 | }) |
| 354 | .into_response() |
| 355 | } |
| 356 | |
| 357 | /// `POST /{owner}/{repo}/-/api/secrets/lock` — forget the values now. |
| 358 | async fn lock( |
| 359 | State(app): State<App>, |
| 360 | CurrentUser(user): CurrentUser, |
| 361 | csrf: Csrf, |
| 362 | Path((owner, repo)): Path<(String, String)>, |
| 363 | headers: HeaderMap, |
| 364 | ) -> Response { |
| 365 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 366 | Ok(m) => m, |
| 367 | Err(resp) => return resp, |
| 368 | }; |
| 369 | app.vault.lock(meta.id); |
| 370 | StatusCode::NO_CONTENT.into_response() |
| 371 | } |
| 372 | |
| 373 | // --- form posts from the settings page ------------------------------------- |
| 374 | |
| 375 | async fn ui_delete( |
| 376 | State(app): State<App>, |
| 377 | CurrentUser(user): CurrentUser, |
| 378 | csrf: Csrf, |
| 379 | Path((owner, repo, name)): Path<(String, String, String)>, |
| 380 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 381 | ) -> Response { |
| 382 | let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await { |
| 383 | Ok(m) => m, |
| 384 | Err(resp) => return resp, |
| 385 | }; |
| 386 | if let Err(e) = secrets::delete(&app.db, meta.id, &name).await { |
| 387 | return server_error(e); |
| 388 | } |
| 389 | Redirect::to(&format!("/{owner}/{repo}/settings")).into_response() |
| 390 | } |
| 391 | |
| 392 | async fn ui_lock( |
| 393 | State(app): State<App>, |
| 394 | CurrentUser(user): CurrentUser, |
| 395 | csrf: Csrf, |
| 396 | Path((owner, repo)): Path<(String, String)>, |
| 397 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 398 | ) -> Response { |
| 399 | let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await { |
| 400 | Ok(m) => m, |
| 401 | Err(resp) => return resp, |
| 402 | }; |
| 403 | app.vault.lock(meta.id); |
| 404 | Redirect::to(&format!("/{owner}/{repo}/settings")).into_response() |
| 405 | } |
| 406 | |
| 407 | async fn ui_authorize( |
| 408 | app: &App, |
| 409 | user: Option<User>, |
| 410 | csrf: &Csrf, |
| 411 | submitted: &str, |
| 412 | owner: &str, |
| 413 | repo: &str, |
| 414 | ) -> Result<Repository, Response> { |
| 415 | verify_csrf(csrf, submitted)?; |
| 416 | let meta = resolve(app, owner, repo).await?; |
| 417 | if !access::can_write(&meta, user.as_ref()) { |
| 418 | return Err((StatusCode::NOT_FOUND, "no such repository").into_response()); |
| 419 | } |
| 420 | Ok(meta) |
| 421 | } |
| 422 | |
| 423 | // --- user secrets (JSON API) ------------------------------------------------- |
| 424 | // |
| 425 | // Same shape as the repository handlers above, minus the repository: the |
| 426 | // target is always the authenticated caller's own account, so there is no |
| 427 | // resolve-and-check-access step — being signed in (or presenting valid Basic |
| 428 | // credentials) is the only authorization a user's own secrets need. |
| 429 | |
| 430 | /// Resolve the authenticated account, the same two ways [`authorize`] does. |
| 431 | async fn user_authorize( |
| 432 | app: &App, |
| 433 | session_user: Option<User>, |
| 434 | csrf: &Csrf, |
| 435 | headers: &HeaderMap, |
| 436 | ) -> Result<User, Response> { |
| 437 | let authorization = headers |
| 438 | .get(axum::http::header::AUTHORIZATION) |
| 439 | .and_then(|v| v.to_str().ok()); |
| 440 | let user = match authorization { |
| 441 | Some(header) if header.to_ascii_lowercase().starts_with("basic ") => { |
| 442 | basic_auth_user(app, Some(header)).await |
| 443 | } |
| 444 | _ => { |
| 445 | let submitted = headers |
| 446 | .get("x-csrf-token") |
| 447 | .and_then(|v| v.to_str().ok()) |
| 448 | .unwrap_or_default(); |
| 449 | verify_csrf(csrf, submitted)?; |
| 450 | session_user |
| 451 | } |
| 452 | }; |
| 453 | user.ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response()) |
| 454 | } |
| 455 | |
| 456 | #[derive(Serialize)] |
| 457 | struct UserSecretsResponse { |
| 458 | account: String, |
| 459 | unlocked_until: i64, |
| 460 | recipients: Vec<RecipientJson>, |
| 461 | secrets: Vec<UserSecretJson>, |
| 462 | } |
| 463 | |
| 464 | #[derive(Serialize)] |
| 465 | struct UserSecretJson { |
| 466 | name: String, |
| 467 | kind: String, |
| 468 | dest_path: String, |
| 469 | field: String, |
| 470 | envelope: serde_json::Value, |
| 471 | recipients: Vec<String>, |
| 472 | updated_at: i64, |
| 473 | } |
| 474 | |
| 475 | /// `GET /-/api/user/secrets`. |
| 476 | async fn list_user_secrets( |
| 477 | State(app): State<App>, |
| 478 | CurrentUser(user): CurrentUser, |
| 479 | csrf: Csrf, |
| 480 | headers: HeaderMap, |
| 481 | ) -> Response { |
| 482 | let user = match user_authorize(&app, user, &csrf, &headers).await { |
| 483 | Ok(u) => u, |
| 484 | Err(resp) => return resp, |
| 485 | }; |
| 486 | let recipients = match recipients_for_user(&app, user.id).await { |
| 487 | Ok(r) => r, |
| 488 | Err(resp) => return resp, |
| 489 | }; |
| 490 | let stored = match secrets::list_for_user(&app.db, user.id).await { |
| 491 | Ok(s) => s, |
| 492 | Err(e) => return server_error(e).into_response(), |
| 493 | }; |
| 494 | let secrets_json = stored |
| 495 | .into_iter() |
| 496 | .map(|s| UserSecretJson { |
| 497 | envelope: serde_json::from_str(&s.envelope).unwrap_or(serde_json::Value::Null), |
| 498 | recipients: split_fingerprints(&s.recipients), |
| 499 | name: s.name, |
| 500 | kind: s.kind, |
| 501 | dest_path: s.dest_path, |
| 502 | field: s.field, |
| 503 | updated_at: s.updated_at, |
| 504 | }) |
| 505 | .collect(); |
| 506 | Json(UserSecretsResponse { |
| 507 | account: user.username, |
| 508 | unlocked_until: app |
| 509 | .user_vault |
| 510 | .status(user.id) |
| 511 | .map(|s| s.expires_at) |
| 512 | .unwrap_or_default(), |
| 513 | recipients: recipients |
| 514 | .into_iter() |
| 515 | .map(|(recipient, line)| RecipientJson { |
| 516 | fingerprint: recipient.fingerprint, |
| 517 | key: line, |
| 518 | }) |
| 519 | .collect(), |
| 520 | secrets: secrets_json, |
| 521 | }) |
| 522 | .into_response() |
| 523 | } |
| 524 | |
| 525 | #[derive(Deserialize)] |
| 526 | struct PutUserSecret { |
| 527 | name: String, |
| 528 | #[serde(default)] |
| 529 | kind: String, |
| 530 | #[serde(default)] |
| 531 | dest_path: String, |
| 532 | #[serde(default)] |
| 533 | field: String, |
| 534 | envelope: serde_json::Value, |
| 535 | } |
| 536 | |
| 537 | /// `POST /-/api/user/secrets` — store a sealed envelope under a name, |
| 538 | /// replacing any previous value. The body is ciphertext; the server checks |
| 539 | /// only its shape (and, for `kind`/`dest_path`/`field`, that they are |
| 540 | /// internally consistent — see `secrets::put_for_user`). |
| 541 | async fn put_user_secret( |
| 542 | State(app): State<App>, |
| 543 | CurrentUser(user): CurrentUser, |
| 544 | csrf: Csrf, |
| 545 | headers: HeaderMap, |
| 546 | Json(body): Json<PutUserSecret>, |
| 547 | ) -> Response { |
| 548 | let user = match user_authorize(&app, user, &csrf, &headers).await { |
| 549 | Ok(u) => u, |
| 550 | Err(resp) => return resp, |
| 551 | }; |
| 552 | let json = match serde_json::to_string(&body.envelope) { |
| 553 | Ok(j) => j, |
| 554 | Err(e) => return bad_request(e), |
| 555 | }; |
| 556 | let envelope = match Envelope::parse(&json) { |
| 557 | Ok(e) => e, |
| 558 | Err(e) => return bad_request(e), |
| 559 | }; |
| 560 | let current = match recipients_for_user(&app, user.id).await { |
| 561 | Ok(r) => r, |
| 562 | Err(resp) => return resp, |
| 563 | }; |
| 564 | let sealed_to = envelope.recipient_fingerprints(); |
| 565 | if !current |
| 566 | .iter() |
| 567 | .any(|(r, _)| sealed_to.contains(&r.fingerprint)) |
| 568 | { |
| 569 | return bad_request("envelope is not sealed to any registered ssh key"); |
| 570 | } |
| 571 | let kind = if body.kind.is_empty() { |
| 572 | secrets::kind::ENV |
| 573 | } else { |
| 574 | &body.kind |
| 575 | }; |
| 576 | match secrets::put_for_user( |
| 577 | &app.db, |
| 578 | user.id, |
| 579 | &body.name, |
| 580 | kind, |
| 581 | &body.dest_path, |
| 582 | &body.field, |
| 583 | &envelope, |
| 584 | ) |
| 585 | .await |
| 586 | { |
| 587 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 588 | Err(e) => bad_request(e), |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | /// `DELETE /-/api/user/secrets/{name}`. |
| 593 | async fn delete_user_secret( |
| 594 | State(app): State<App>, |
| 595 | CurrentUser(user): CurrentUser, |
| 596 | csrf: Csrf, |
| 597 | Path(name): Path<String>, |
| 598 | headers: HeaderMap, |
| 599 | ) -> Response { |
| 600 | let user = match user_authorize(&app, user, &csrf, &headers).await { |
| 601 | Ok(u) => u, |
| 602 | Err(resp) => return resp, |
| 603 | }; |
| 604 | match secrets::delete_for_user(&app.db, user.id, &name).await { |
| 605 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 606 | Err(e) => server_error(e), |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | /// `POST /-/api/user/secrets/unlock` — hand the server decrypted values to |
| 611 | /// hold in memory for agent sessions until they expire. See [`unlock`]. |
| 612 | async fn unlock_user( |
| 613 | State(app): State<App>, |
| 614 | CurrentUser(user): CurrentUser, |
| 615 | csrf: Csrf, |
| 616 | headers: HeaderMap, |
| 617 | Json(body): Json<UnlockBody>, |
| 618 | ) -> Response { |
| 619 | let user = match user_authorize(&app, user, &csrf, &headers).await { |
| 620 | Ok(u) => u, |
| 621 | Err(resp) => return resp, |
| 622 | }; |
| 623 | for name in body.values.keys() { |
| 624 | if !secrets::valid_name(name) { |
| 625 | return bad_request(format!("invalid secret name `{name}`")); |
| 626 | } |
| 627 | } |
| 628 | let count = body.values.len(); |
| 629 | let ttl = if body.ttl_secs > 0 { |
| 630 | body.ttl_secs |
| 631 | } else { |
| 632 | 8 * 60 * 60 |
| 633 | }; |
| 634 | let unlocked_until = app.user_vault.unlock(user.id, body.values, ttl); |
| 635 | tracing::info!( |
| 636 | "secrets: {}'s user secrets unlocked with {count} value(s) until {unlocked_until}", |
| 637 | user.username |
| 638 | ); |
| 639 | Json(UnlockResponse { |
| 640 | unlocked_until, |
| 641 | count, |
| 642 | }) |
| 643 | .into_response() |
| 644 | } |
| 645 | |
| 646 | /// `POST /-/api/user/secrets/lock` — forget the values now. |
| 647 | async fn lock_user( |
| 648 | State(app): State<App>, |
| 649 | CurrentUser(user): CurrentUser, |
| 650 | csrf: Csrf, |
| 651 | headers: HeaderMap, |
| 652 | ) -> Response { |
| 653 | let user = match user_authorize(&app, user, &csrf, &headers).await { |
| 654 | Ok(u) => u, |
| 655 | Err(resp) => return resp, |
| 656 | }; |
| 657 | app.user_vault.lock(user.id); |
| 658 | StatusCode::NO_CONTENT.into_response() |
| 659 | } |
| 660 | |
| 661 | // --- form posts from the account settings page ------------------------------ |
| 662 | |
| 663 | async fn ui_delete_user( |
| 664 | State(app): State<App>, |
| 665 | CurrentUser(user): CurrentUser, |
| 666 | csrf: Csrf, |
| 667 | Path(name): Path<String>, |
| 668 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 669 | ) -> Response { |
| 670 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { |
| 671 | return resp; |
| 672 | } |
| 673 | let Some(user) = user else { |
| 674 | return (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response(); |
| 675 | }; |
| 676 | if let Err(e) = secrets::delete_for_user(&app.db, user.id, &name).await { |
| 677 | return server_error(e); |
| 678 | } |
| 679 | Redirect::to("/-/settings").into_response() |
| 680 | } |
| 681 | |
| 682 | async fn ui_lock_user( |
| 683 | State(app): State<App>, |
| 684 | CurrentUser(user): CurrentUser, |
| 685 | csrf: Csrf, |
| 686 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 687 | ) -> Response { |
| 688 | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { |
| 689 | return resp; |
| 690 | } |
| 691 | let Some(user) = user else { |
| 692 | return (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response(); |
| 693 | }; |
| 694 | app.user_vault.lock(user.id); |
| 695 | Redirect::to("/-/settings").into_response() |
| 696 | } |
| 697 | |
| 698 | /// The signed-in user's own ssh-ed25519 keys, as (recipient, OpenSSH line) — |
| 699 | /// same filter as [`recipients_for`], just against an account id directly. |
| 700 | async fn recipients_for_user( |
| 701 | app: &App, |
| 702 | user_id: i64, |
| 703 | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { |
| 704 | let keys = ssh_keys::list_by_user(&app.db, user_id) |
| 705 | .await |
| 706 | .map_err(server_error)?; |
| 707 | Ok(keys |
| 708 | .into_iter() |
| 709 | .filter_map(|k| { |
| 710 | secrets::Recipient::from_openssh(&k.content) |
| 711 | .ok() |
| 712 | .map(|r| (r, k.content)) |
| 713 | }) |
| 714 | .collect()) |
| 715 | } |
| 716 | |
| 717 | // --- shared helpers -------------------------------------------------------- |
| 718 | |
| 719 | fn split_fingerprints(csv: &str) -> Vec<String> { |
| 720 | csv.split(',') |
| 721 | .filter(|s| !s.is_empty()) |
| 722 | .map(str::to_string) |
| 723 | .collect() |
| 724 | } |
| 725 | |
| 726 | /// The repository owner's ssh-ed25519 keys, as (recipient, OpenSSH line). |
| 727 | /// Other key types are skipped: they cannot do X25519 key agreement. |
| 728 | async fn recipients_for( |
| 729 | app: &App, |
| 730 | meta: &Repository, |
| 731 | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { |
| 732 | let keys = ssh_keys::list_by_user(&app.db, meta.owner_id) |
| 733 | .await |
| 734 | .map_err(server_error)?; |
| 735 | Ok(keys |
| 736 | .into_iter() |
| 737 | .filter_map(|k| { |
| 738 | secrets::Recipient::from_openssh(&k.content) |
| 739 | .ok() |
| 740 | .map(|r| (r, k.content)) |
| 741 | }) |
| 742 | .collect()) |
| 743 | } |
| 744 | |
| 745 | /// Coarse countdown phrasing ("3 hours"), since [`fmt_relative`] only ever |
| 746 | /// looks backwards. |
| 747 | fn fmt_duration(secs: i64) -> String { |
| 748 | let plural = |n: i64, unit: &str| { |
| 749 | if n == 1 { |
| 750 | format!("1 {unit}") |
| 751 | } else { |
| 752 | format!("{n} {unit}s") |
| 753 | } |
| 754 | }; |
| 755 | match secs { |
| 756 | s if s <= 0 => "moments".to_string(), |
| 757 | s if s < 60 => plural(s, "second"), |
| 758 | s if s < 3600 => plural(s / 60, "minute"), |
| 759 | s if s < 86_400 => plural(s / 3600, "hour"), |
| 760 | s => plural(s / 86_400, "day"), |
| 761 | } |
| 762 | } |
| 763 | |
| 764 | /// The secrets section of a repository's settings page. |
| 765 | pub async fn settings_section(app: &App, owner: &str, repo: &str, meta: &Repository) -> Markup { |
| 766 | let recipients = recipients_for(app, meta).await.unwrap_or_default(); |
| 767 | let stored = secrets::list(&app.db, meta.id).await.unwrap_or_default(); |
| 768 | let status = app.vault.status(meta.id); |
| 769 | let csrf = crate::auth::current_csrf(); |
| 770 | |
| 771 | let recipients_json = serde_json::to_string( |
| 772 | &recipients |
| 773 | .iter() |
| 774 | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) |
| 775 | .collect::<Vec<_>>(), |
| 776 | ) |
| 777 | .unwrap_or_else(|_| "[]".to_string()); |
| 778 | let current: Vec<&str> = recipients |
| 779 | .iter() |
| 780 | .map(|(r, _)| r.fingerprint.as_str()) |
| 781 | .collect(); |
| 782 | |
| 783 | html! { |
| 784 | h2 style="margin-top:28px" { "Secrets" } |
| 785 | p.muted style="font-size:13px" { |
| 786 | "Encrypted in your browser to your ssh-ed25519 keys before they are sent. " |
| 787 | "anvil stores only the ciphertext and cannot read it — not here, not in a backup. " |
| 788 | "To let CI use them, run " |
| 789 | code { "anvild secret unlock " (owner) "/" (repo) } |
| 790 | " from a machine holding one of those keys." |
| 791 | } |
| 792 | |
| 793 | @if let Some(status) = status { |
| 794 | p.secret-unlocked { |
| 795 | "Unlocked for CI — " (status.count) " value(s), expires in " |
| 796 | (fmt_duration(status.expires_at - anvil_core::secrets::now_secs())) "." |
| 797 | form method="post" action=(format!("/{owner}/{repo}/-/secrets/lock")) style="display:inline;margin-left:8px" { |
| 798 | (csrf_input(&csrf)) |
| 799 | button.btn.btn-secondary type="submit" { "Lock now" } |
| 800 | } |
| 801 | } |
| 802 | } @else { |
| 803 | p.muted style="font-size:13px" { "Sealed: CI runs that declare secrets will fail until you unlock." } |
| 804 | } |
| 805 | |
| 806 | @if stored.is_empty() { |
| 807 | p.muted { "No secrets yet." } |
| 808 | } @else { |
| 809 | div.box { |
| 810 | @for s in &stored { |
| 811 | div.row { |
| 812 | span { |
| 813 | code { (s.name) } |
| 814 | @let sealed_to = split_fingerprints(&s.recipients); |
| 815 | @let missing = current.iter().filter(|fp| !sealed_to.iter().any(|s| s == **fp)).count(); |
| 816 | @if missing > 0 { |
| 817 | span.secret-stale title="Sealed before these keys were added" { |
| 818 | (missing) " key(s) cannot open this — rekey" |
| 819 | } |
| 820 | } |
| 821 | } |
| 822 | span.muted style="margin-left:auto;font-size:13px" { |
| 823 | "updated " (fmt_relative(s.updated_at)) |
| 824 | } |
| 825 | form method="post" style="margin-left:12px" |
| 826 | action=(format!("/{owner}/{repo}/-/secrets/{}/delete", s.name)) { |
| 827 | (csrf_input(&csrf)) |
| 828 | button.btn.btn-secondary type="submit" { "Delete" } |
| 829 | } |
| 830 | } |
| 831 | } |
| 832 | } |
| 833 | } |
| 834 | |
| 835 | @if recipients.is_empty() { |
| 836 | p.secret-warn { |
| 837 | "No ssh-ed25519 key registered, so there is nothing to encrypt to. " |
| 838 | a href="/-/settings" { "Add one" } " first." |
| 839 | } |
| 840 | } @else { |
| 841 | // Deliberately not a <form>: with no form element there is no |
| 842 | // default submission path that could ever put a plaintext value in |
| 843 | // a request the browser builds by itself. |
| 844 | div #secrets-form.stack |
| 845 | data-repo=(format!("{owner}/{repo}")) |
| 846 | data-endpoint=(format!("/{owner}/{repo}/-/api/secrets")) |
| 847 | data-csrf=(csrf) |
| 848 | style="margin-top:16px" { |
| 849 | script #secret-recipients type="application/json" { (PreEscaped(recipients_json)) } |
| 850 | p { |
| 851 | label { "Name" br; input #secret-name type="text" placeholder="DEPLOY_TOKEN" autocomplete="off"; } |
| 852 | } |
| 853 | p { |
| 854 | label { "Value" br; textarea #secret-value rows="3" autocomplete="off" spellcheck="false" {} } |
| 855 | br; |
| 856 | span.muted style="font-size:12px" { |
| 857 | "Sealed to " (recipients.len()) " key(s) in this browser. The value never leaves the page in the clear." |
| 858 | } |
| 859 | } |
| 860 | p { |
| 861 | button.btn #secret-save type="button" { "Encrypt and save" } |
| 862 | span #secret-status.muted style="margin-left:10px;font-size:13px" {} |
| 863 | } |
| 864 | } |
| 865 | script { (PreEscaped(SEAL_JS)) } |
| 866 | script { (PreEscaped(FORM_JS)) } |
| 867 | } |
| 868 | } |
| 869 | } |
| 870 | |
| 871 | /// The secrets section of the account settings page (`/-/settings`). |
| 872 | /// Mirrors [`settings_section`] — see there for the general shape and the |
| 873 | /// "why not a `<form>`" note — but sealed to the account's own keys |
| 874 | /// (`user:{username}` rather than `{owner}/{repo}` as the AAD scope) and |
| 875 | /// consumed by that account's own agent sessions rather than CI. A secret |
| 876 | /// also carries a kind (env/file/json — [`secrets::kind`]) and, for |
| 877 | /// file/json, a destination path under the session's `$HOME`. |
| 878 | pub async fn user_settings_section(app: &App, user: &User) -> Markup { |
| 879 | let recipients = recipients_for_user(app, user.id).await.unwrap_or_default(); |
| 880 | let stored = secrets::list_for_user(&app.db, user.id) |
| 881 | .await |
| 882 | .unwrap_or_default(); |
| 883 | let status = app.user_vault.status(user.id); |
| 884 | let csrf = crate::auth::current_csrf(); |
| 885 | |
| 886 | let recipients_json = serde_json::to_string( |
| 887 | &recipients |
| 888 | .iter() |
| 889 | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) |
| 890 | .collect::<Vec<_>>(), |
| 891 | ) |
| 892 | .unwrap_or_else(|_| "[]".to_string()); |
| 893 | let current: Vec<&str> = recipients |
| 894 | .iter() |
| 895 | .map(|(r, _)| r.fingerprint.as_str()) |
| 896 | .collect(); |
| 897 | |
| 898 | html! { |
| 899 | h2 style="margin-top:28px" { "Secrets" } |
| 900 | p.muted style="font-size:13px" { |
| 901 | "Encrypted in your browser to your own ssh-ed25519 keys before they are sent. " |
| 902 | "For your own agent sessions to use one, it opts in by name when you start it — " |
| 903 | "and it needs unlocking first: run " |
| 904 | code { "anvild secret user unlock" } |
| 905 | " from a machine holding one of those keys." |
| 906 | } |
| 907 | |
| 908 | @if let Some(status) = status { |
| 909 | p.secret-unlocked { |
| 910 | "Unlocked — " (status.count) " value(s), expires in " |
| 911 | (fmt_duration(status.expires_at - secrets::now_secs())) "." |
| 912 | form method="post" action="/-/settings/secrets/lock" style="display:inline;margin-left:8px" { |
| 913 | (csrf_input(&csrf)) |
| 914 | button.btn.btn-secondary type="submit" { "Lock now" } |
| 915 | } |
| 916 | } |
| 917 | } @else { |
| 918 | p.muted style="font-size:13px" { "Sealed: sessions that opt into one of these will fail to start until you unlock." } |
| 919 | } |
| 920 | |
| 921 | @if stored.is_empty() { |
| 922 | p.muted { "No secrets yet." } |
| 923 | } @else { |
| 924 | div.box { |
| 925 | @for s in &stored { |
| 926 | div.row { |
| 927 | span { |
| 928 | code { (s.name) } |
| 929 | " (" (s.kind) |
| 930 | @if !s.dest_path.is_empty() { " → " code { (s.dest_path) } } |
| 931 | @if !s.field.is_empty() { " " code { (s.field) } } |
| 932 | ")" |
| 933 | @let sealed_to = split_fingerprints(&s.recipients); |
| 934 | @let missing = current.iter().filter(|fp| !sealed_to.iter().any(|s| s == **fp)).count(); |
| 935 | @if missing > 0 { |
| 936 | span.secret-stale title="Sealed before these keys were added" { |
| 937 | (missing) " key(s) cannot open this — rekey" |
| 938 | } |
| 939 | } |
| 940 | } |
| 941 | span.muted style="margin-left:auto;font-size:13px" { |
| 942 | "updated " (fmt_relative(s.updated_at)) |
| 943 | } |
| 944 | form method="post" style="margin-left:12px" |
| 945 | action=(format!("/-/settings/secrets/{}/delete", s.name)) { |
| 946 | (csrf_input(&csrf)) |
| 947 | button.btn.btn-secondary type="submit" { "Delete" } |
| 948 | } |
| 949 | } |
| 950 | } |
| 951 | } |
| 952 | } |
| 953 | |
| 954 | @if recipients.is_empty() { |
| 955 | p.secret-warn { "No ssh-ed25519 key registered, so there is nothing to encrypt to. Add one above first." } |
| 956 | } @else { |
| 957 | div #user-secrets-form.stack |
| 958 | data-scope=(format!("user:{}", user.username)) |
| 959 | data-endpoint="/-/api/user/secrets" |
| 960 | data-csrf=(csrf) |
| 961 | style="margin-top:16px" { |
| 962 | script #user-secret-recipients type="application/json" { (PreEscaped(recipients_json)) } |
| 963 | p { |
| 964 | label { "Name" br; input #user-secret-name type="text" placeholder="CLAUDE_CREDS" autocomplete="off"; } |
| 965 | } |
| 966 | p { |
| 967 | label { "Kind" br; |
| 968 | select #user-secret-kind { |
| 969 | option value="env" { "env — an environment variable named after this secret" } |
| 970 | option value="file" { "file — write the whole value at a path" } |
| 971 | option value="json" { "json — set one field of a JSON file at a path" } |
| 972 | } |
| 973 | } |
| 974 | } |
| 975 | p #user-secret-path-row style="display:none" { |
| 976 | label { "Path (under $HOME in the session)" br; |
| 977 | input #user-secret-path type="text" placeholder=".claude/.credentials.json" autocomplete="off"; |
| 978 | } |
| 979 | } |
| 980 | p #user-secret-field-row style="display:none" { |
| 981 | label { "Field (jq path within that file)" br; |
| 982 | input #user-secret-field type="text" placeholder=".oauthAccount.token" autocomplete="off"; |
| 983 | } |
| 984 | } |
| 985 | p { |
| 986 | label { "Value" br; textarea #user-secret-value rows="3" autocomplete="off" spellcheck="false" {} } |
| 987 | br; |
| 988 | span.muted style="font-size:12px" { |
| 989 | "Sealed to " (recipients.len()) " key(s) in this browser. The value never leaves the page in the clear." |
| 990 | } |
| 991 | } |
| 992 | p { |
| 993 | button.btn #user-secret-save type="button" { "Encrypt and save" } |
| 994 | span #user-secret-status.muted style="margin-left:10px;font-size:13px" {} |
| 995 | } |
| 996 | } |
| 997 | script { (PreEscaped(SEAL_JS)) } |
| 998 | script { (PreEscaped(USER_FORM_JS)) } |
| 999 | } |
| 1000 | } |
| 1001 | } |
| 1002 | |
| 1003 | /// Browser-side sealing, exposed as `anvilSealSecret(repo, name, value, |
| 1004 | /// recipients)`. |
| 1005 | /// |
| 1006 | /// Mirrors [`anvil_core::secrets::seal`] exactly — same derivation, same |
| 1007 | /// associated data, same field encoding — so the CLI can open what the browser |
| 1008 | /// wrote and vice versa. `tests/js_interop.rs` runs this very string under node |
| 1009 | /// and opens the result in Rust, which is what keeps the two halves honest. |
| 1010 | /// |
| 1011 | /// Every primitive is WebCrypto's; nothing here implements a cipher by hand. |
| 1012 | /// The one piece of arithmetic is the Edwards → Montgomery map of the |
| 1013 | /// recipient's public key, for which WebCrypto has no API. |
| 1014 | pub const SEAL_JS: &str = r#" |
| 1015 | globalThis.anvilSealSecret = (function () { |
| 1016 | var te = new TextEncoder(); |
| 1017 | |
| 1018 | function b64(bytes) { |
| 1019 | var s = ''; |
| 1020 | for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); |
| 1021 | return btoa(s); |
| 1022 | } |
| 1023 | |
| 1024 | // An OpenSSH public-key line holds a base64 blob of length-prefixed fields: |
| 1025 | // the algorithm name, then the 32-byte Ed25519 point. |
| 1026 | function ed25519FromLine(line) { |
| 1027 | var blob = Uint8Array.from(atob(line.trim().split(/\s+/)[1]), function (c) { return c.charCodeAt(0); }); |
| 1028 | var off = 0; |
| 1029 | function field() { |
| 1030 | var n = (blob[off] << 24) | (blob[off + 1] << 16) | (blob[off + 2] << 8) | blob[off + 3]; |
| 1031 | off += 4; |
| 1032 | var out = blob.slice(off, off + n); |
| 1033 | off += n; |
| 1034 | return out; |
| 1035 | } |
| 1036 | if (new TextDecoder().decode(field()) !== 'ssh-ed25519') throw new Error('not an ssh-ed25519 key'); |
| 1037 | var key = field(); |
| 1038 | if (key.length !== 32) throw new Error('malformed ed25519 key'); |
| 1039 | return key; |
| 1040 | } |
| 1041 | |
| 1042 | // u = (1 + y) / (1 - y) mod 2^255-19: the birational map from the Edwards |
| 1043 | // curve Ed25519 signs on to the Montgomery curve X25519 agrees on. |
| 1044 | var P = (1n << 255n) - 19n; |
| 1045 | function inverse(a) { |
| 1046 | var result = 1n, base = ((a % P) + P) % P, e = P - 2n; |
| 1047 | while (e > 0n) { |
| 1048 | if (e & 1n) result = (result * base) % P; |
| 1049 | base = (base * base) % P; |
| 1050 | e >>= 1n; |
| 1051 | } |
| 1052 | return result; |
| 1053 | } |
| 1054 | function toMontgomery(ed) { |
| 1055 | var b = Uint8Array.from(ed); |
| 1056 | b[31] &= 0x7f; // drop the sign bit; only y matters |
| 1057 | var y = 0n; |
| 1058 | for (var i = 31; i >= 0; i--) y = (y << 8n) | BigInt(b[i]); |
| 1059 | var den = ((1n - y) % P + P) % P; |
| 1060 | if (den === 0n) throw new Error('degenerate key'); |
| 1061 | var u = ((1n + y) % P) * inverse(den) % P; |
| 1062 | var out = new Uint8Array(32); |
| 1063 | for (var j = 0; j < 32; j++) { out[j] = Number(u & 0xffn); u >>= 8n; } |
| 1064 | return out; |
| 1065 | } |
| 1066 | |
| 1067 | async function aesEncrypt(key, nonce, aad, data) { |
| 1068 | var k = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt']); |
| 1069 | return new Uint8Array(await crypto.subtle.encrypt( |
| 1070 | { name: 'AES-GCM', iv: nonce, additionalData: aad }, k, data)); |
| 1071 | } |
| 1072 | |
| 1073 | return async function sealSecret(repo, name, value, recipients) { |
| 1074 | var fileKey = crypto.getRandomValues(new Uint8Array(32)); |
| 1075 | var nonce = crypto.getRandomValues(new Uint8Array(12)); |
| 1076 | var aad = te.encode('anvil-secret-v1\n' + repo + '\n' + name); |
| 1077 | var ct = await aesEncrypt(fileKey, nonce, aad, te.encode(value)); |
| 1078 | |
| 1079 | var stanzas = []; |
| 1080 | for (var i = 0; i < recipients.length; i++) { |
| 1081 | var r = recipients[i]; |
| 1082 | var u = toMontgomery(ed25519FromLine(r.key)); |
| 1083 | var pub = await crypto.subtle.importKey('raw', u, { name: 'X25519' }, false, []); |
| 1084 | var eph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); |
| 1085 | var epk = new Uint8Array(await crypto.subtle.exportKey('raw', eph.publicKey)); |
| 1086 | var shared = new Uint8Array(await crypto.subtle.deriveBits( |
| 1087 | { name: 'X25519', public: pub }, eph.privateKey, 256)); |
| 1088 | var salt = new Uint8Array(64); |
| 1089 | salt.set(epk, 0); |
| 1090 | salt.set(u, 32); |
| 1091 | var ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']); |
| 1092 | var okm = new Uint8Array(await crypto.subtle.deriveBits( |
| 1093 | { name: 'HKDF', hash: 'SHA-256', salt: salt, info: te.encode('anvil-secret-v1 wrap') }, |
| 1094 | ikm, 256)); |
| 1095 | var wrapNonce = crypto.getRandomValues(new Uint8Array(12)); |
| 1096 | var wrapped = await aesEncrypt(okm, wrapNonce, te.encode(r.fingerprint), fileKey); |
| 1097 | var wrap = new Uint8Array(12 + wrapped.length); |
| 1098 | wrap.set(wrapNonce, 0); |
| 1099 | wrap.set(wrapped, 12); |
| 1100 | stanzas.push({ fp: r.fingerprint, epk: b64(epk), wrap: b64(wrap) }); |
| 1101 | } |
| 1102 | return { v: 1, alg: 'x25519-hkdf-sha256+aes256gcm', recipients: stanzas, nonce: b64(nonce), ct: b64(ct) }; |
| 1103 | }; |
| 1104 | })(); |
| 1105 | "#; |
| 1106 | |
| 1107 | /// Wires the settings form to [`SEAL_JS`]: validate, seal, POST the envelope. |
| 1108 | /// The plaintext lives in one textarea and is cleared as soon as the ciphertext |
| 1109 | /// is on its way. |
| 1110 | const FORM_JS: &str = r#" |
| 1111 | (function () { |
| 1112 | var root = document.getElementById('secrets-form'); |
| 1113 | if (!root) return; |
| 1114 | var nameEl = document.getElementById('secret-name'); |
| 1115 | var valueEl = document.getElementById('secret-value'); |
| 1116 | var button = document.getElementById('secret-save'); |
| 1117 | var statusEl = document.getElementById('secret-status'); |
| 1118 | var recipients = JSON.parse(document.getElementById('secret-recipients').textContent); |
| 1119 | |
| 1120 | function fail(message) { |
| 1121 | statusEl.textContent = message; |
| 1122 | statusEl.style.color = 'var(--error)'; |
| 1123 | button.disabled = false; |
| 1124 | } |
| 1125 | |
| 1126 | button.addEventListener('click', async function () { |
| 1127 | var name = nameEl.value.trim(); |
| 1128 | var value = valueEl.value; |
| 1129 | statusEl.style.color = ''; |
| 1130 | if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) return fail('Name must be A-Z, 0-9 and _, not starting with a digit.'); |
| 1131 | if (!value) return fail('Value is empty.'); |
| 1132 | if (!crypto.subtle || !window.BigInt) return fail('This browser cannot encrypt here; use `anvild secret set`.'); |
| 1133 | |
| 1134 | button.disabled = true; |
| 1135 | statusEl.textContent = 'Encrypting…'; |
| 1136 | var envelope; |
| 1137 | try { |
| 1138 | envelope = await anvilSealSecret(root.dataset.repo, name, value, recipients); |
| 1139 | } catch (e) { |
| 1140 | // Most likely cause: a browser without WebCrypto X25519. |
| 1141 | return fail('Encryption failed (' + e.message + '). Use `anvild secret set` instead.'); |
| 1142 | } |
| 1143 | statusEl.textContent = 'Saving…'; |
| 1144 | try { |
| 1145 | var res = await fetch(root.dataset.endpoint, { |
| 1146 | method: 'POST', |
| 1147 | headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }, |
| 1148 | body: JSON.stringify({ name: name, envelope: envelope }), |
| 1149 | }); |
| 1150 | if (!res.ok) return fail('Server rejected it: ' + (await res.text())); |
| 1151 | } catch (e) { |
| 1152 | return fail('Could not reach the server: ' + e.message); |
| 1153 | } |
| 1154 | // Clear the plaintext out of the DOM before the page goes away. |
| 1155 | valueEl.value = ''; |
| 1156 | nameEl.value = ''; |
| 1157 | location.reload(); |
| 1158 | }); |
| 1159 | })(); |
| 1160 | "#; |
| 1161 | |
| 1162 | /// Wires the account settings form to [`SEAL_JS`], same as [`FORM_JS`] but |
| 1163 | /// for a user secret: a kind selector (env/file/json) that shows/hides the |
| 1164 | /// path and field inputs, both included in the POST body alongside the |
| 1165 | /// envelope. The envelope itself is sealed exactly the same way — `kind`, |
| 1166 | /// `dest_path`, and `field` are metadata the server stores next to it, never |
| 1167 | /// part of what gets encrypted. |
| 1168 | const USER_FORM_JS: &str = r#" |
| 1169 | (function () { |
| 1170 | var root = document.getElementById('user-secrets-form'); |
| 1171 | if (!root) return; |
| 1172 | var nameEl = document.getElementById('user-secret-name'); |
| 1173 | var kindEl = document.getElementById('user-secret-kind'); |
| 1174 | var pathRow = document.getElementById('user-secret-path-row'); |
| 1175 | var pathEl = document.getElementById('user-secret-path'); |
| 1176 | var fieldRow = document.getElementById('user-secret-field-row'); |
| 1177 | var fieldEl = document.getElementById('user-secret-field'); |
| 1178 | var valueEl = document.getElementById('user-secret-value'); |
| 1179 | var button = document.getElementById('user-secret-save'); |
| 1180 | var statusEl = document.getElementById('user-secret-status'); |
| 1181 | var recipients = JSON.parse(document.getElementById('user-secret-recipients').textContent); |
| 1182 | |
| 1183 | function syncKindFields() { |
| 1184 | var kind = kindEl.value; |
| 1185 | pathRow.style.display = (kind === 'file' || kind === 'json') ? '' : 'none'; |
| 1186 | fieldRow.style.display = (kind === 'json') ? '' : 'none'; |
| 1187 | } |
| 1188 | kindEl.addEventListener('change', syncKindFields); |
| 1189 | syncKindFields(); |
| 1190 | |
| 1191 | function fail(message) { |
| 1192 | statusEl.textContent = message; |
| 1193 | statusEl.style.color = 'var(--error)'; |
| 1194 | button.disabled = false; |
| 1195 | } |
| 1196 | |
| 1197 | button.addEventListener('click', async function () { |
| 1198 | var name = nameEl.value.trim(); |
| 1199 | var kind = kindEl.value; |
| 1200 | var path = pathEl.value.trim(); |
| 1201 | var field = fieldEl.value.trim(); |
| 1202 | var value = valueEl.value; |
| 1203 | statusEl.style.color = ''; |
| 1204 | if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) return fail('Name must be A-Z, 0-9 and _, not starting with a digit.'); |
| 1205 | if ((kind === 'file' || kind === 'json') && !path) return fail('Path is required for this kind.'); |
| 1206 | if (kind === 'json' && !field) return fail('Field (jq path) is required for the json kind.'); |
| 1207 | if (!value) return fail('Value is empty.'); |
| 1208 | if (!crypto.subtle || !window.BigInt) return fail('This browser cannot encrypt here; use `anvild secret user set`.'); |
| 1209 | |
| 1210 | button.disabled = true; |
| 1211 | statusEl.textContent = 'Encrypting…'; |
| 1212 | var envelope; |
| 1213 | try { |
| 1214 | envelope = await anvilSealSecret(root.dataset.scope, name, value, recipients); |
| 1215 | } catch (e) { |
| 1216 | // Most likely cause: a browser without WebCrypto X25519. |
| 1217 | return fail('Encryption failed (' + e.message + '). Use `anvild secret user set` instead.'); |
| 1218 | } |
| 1219 | statusEl.textContent = 'Saving…'; |
| 1220 | try { |
| 1221 | var res = await fetch(root.dataset.endpoint, { |
| 1222 | method: 'POST', |
| 1223 | headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }, |
| 1224 | body: JSON.stringify({ |
| 1225 | name: name, |
| 1226 | kind: kind, |
| 1227 | dest_path: (kind === 'file' || kind === 'json') ? path : '', |
| 1228 | field: (kind === 'json') ? field : '', |
| 1229 | envelope: envelope, |
| 1230 | }), |
| 1231 | }); |
| 1232 | if (!res.ok) return fail('Server rejected it: ' + (await res.text())); |
| 1233 | } catch (e) { |
| 1234 | return fail('Could not reach the server: ' + e.message); |
| 1235 | } |
| 1236 | // Clear the plaintext out of the DOM before the page goes away. |
| 1237 | valueEl.value = ''; |
| 1238 | nameEl.value = ''; |
| 1239 | pathEl.value = ''; |
| 1240 | fieldEl.value = ''; |
| 1241 | location.reload(); |
| 1242 | }); |
| 1243 | })(); |
| 1244 | "#; |