| 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 | } |
| 85 | |
| 86 | // --- request plumbing ------------------------------------------------------ |
| 87 | |
| 88 | /// Resolve the repository and check write access, accepting either a signed-in |
| 89 | /// session (with a CSRF token, as the browser sends) or HTTP Basic credentials |
| 90 | /// (as the CLI sends). Browsers never attach Basic credentials on their own, so |
| 91 | /// the Basic path needs no CSRF defence; the session path always does. |
| 92 | async fn authorize( |
| 93 | app: &App, |
| 94 | session_user: Option<User>, |
| 95 | csrf: &Csrf, |
| 96 | headers: &HeaderMap, |
| 97 | owner: &str, |
| 98 | repo: &str, |
| 99 | ) -> Result<Repository, Response> { |
| 100 | let authorization = headers |
| 101 | .get(axum::http::header::AUTHORIZATION) |
| 102 | .and_then(|v| v.to_str().ok()); |
| 103 | let user = match authorization { |
| 104 | Some(header) if header.to_ascii_lowercase().starts_with("basic ") => { |
| 105 | basic_auth_user(app, Some(header)).await |
| 106 | } |
| 107 | _ => { |
| 108 | let submitted = headers |
| 109 | .get("x-csrf-token") |
| 110 | .and_then(|v| v.to_str().ok()) |
| 111 | .unwrap_or_default(); |
| 112 | verify_csrf(csrf, submitted)?; |
| 113 | session_user |
| 114 | } |
| 115 | }; |
| 116 | let Some(user) = user else { |
| 117 | return Err((StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response()); |
| 118 | }; |
| 119 | let meta = resolve(app, owner, repo).await?; |
| 120 | if !access::can_write(&meta, Some(&user)) { |
| 121 | return Err((StatusCode::NOT_FOUND, "no such repository").into_response()); |
| 122 | } |
| 123 | Ok(meta) |
| 124 | } |
| 125 | |
| 126 | async fn resolve(app: &App, owner: &str, repo: &str) -> Result<Repository, Response> { |
| 127 | let user = users::find_by_username(&app.db, owner) |
| 128 | .await |
| 129 | .map_err(server_error)?; |
| 130 | let meta = match user { |
| 131 | Some(u) => repos::find(&app.db, u.id, repo) |
| 132 | .await |
| 133 | .map_err(server_error)?, |
| 134 | None => None, |
| 135 | }; |
| 136 | meta.ok_or_else(|| (StatusCode::NOT_FOUND, "no such repository").into_response()) |
| 137 | } |
| 138 | |
| 139 | fn server_error(e: impl std::fmt::Display) -> Response { |
| 140 | tracing::error!("secrets: {e}"); |
| 141 | (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() |
| 142 | } |
| 143 | |
| 144 | fn bad_request(e: impl std::fmt::Display) -> Response { |
| 145 | (StatusCode::BAD_REQUEST, e.to_string()).into_response() |
| 146 | } |
| 147 | |
| 148 | // --- JSON API -------------------------------------------------------------- |
| 149 | |
| 150 | #[derive(Serialize)] |
| 151 | struct SecretsResponse { |
| 152 | repo: String, |
| 153 | /// Unix time the current unlock expires, or 0 when sealed. |
| 154 | unlocked_until: i64, |
| 155 | /// The ssh-ed25519 keys secrets must be sealed to, i.e. the owner's. |
| 156 | recipients: Vec<RecipientJson>, |
| 157 | secrets: Vec<SecretJson>, |
| 158 | } |
| 159 | |
| 160 | #[derive(Serialize)] |
| 161 | struct RecipientJson { |
| 162 | fingerprint: String, |
| 163 | /// The OpenSSH public-key line, so a client can seal without re-fetching. |
| 164 | key: String, |
| 165 | } |
| 166 | |
| 167 | #[derive(Serialize)] |
| 168 | struct SecretJson { |
| 169 | name: String, |
| 170 | envelope: serde_json::Value, |
| 171 | recipients: Vec<String>, |
| 172 | updated_at: i64, |
| 173 | } |
| 174 | |
| 175 | /// `GET /{owner}/{repo}/-/api/secrets` — the sealed envelopes plus the current |
| 176 | /// recipient set. Readable only by someone who could write them anyway; the |
| 177 | /// envelopes are useless without a private key regardless. |
| 178 | async fn list_secrets( |
| 179 | State(app): State<App>, |
| 180 | CurrentUser(user): CurrentUser, |
| 181 | csrf: Csrf, |
| 182 | Path((owner, repo)): Path<(String, String)>, |
| 183 | headers: HeaderMap, |
| 184 | ) -> Response { |
| 185 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 186 | Ok(m) => m, |
| 187 | Err(resp) => return resp, |
| 188 | }; |
| 189 | let recipients = match recipients_for(&app, &meta).await { |
| 190 | Ok(r) => r, |
| 191 | Err(resp) => return resp, |
| 192 | }; |
| 193 | let stored = match secrets::list(&app.db, meta.id).await { |
| 194 | Ok(s) => s, |
| 195 | Err(e) => return server_error(e).into_response(), |
| 196 | }; |
| 197 | let secrets_json = stored |
| 198 | .into_iter() |
| 199 | .map(|s| SecretJson { |
| 200 | envelope: serde_json::from_str(&s.envelope).unwrap_or(serde_json::Value::Null), |
| 201 | recipients: split_fingerprints(&s.recipients), |
| 202 | name: s.name, |
| 203 | updated_at: s.updated_at, |
| 204 | }) |
| 205 | .collect(); |
| 206 | Json(SecretsResponse { |
| 207 | repo: format!("{owner}/{repo}"), |
| 208 | unlocked_until: app |
| 209 | .vault |
| 210 | .status(meta.id) |
| 211 | .map(|s| s.expires_at) |
| 212 | .unwrap_or_default(), |
| 213 | recipients: recipients |
| 214 | .into_iter() |
| 215 | .map(|(recipient, line)| RecipientJson { |
| 216 | fingerprint: recipient.fingerprint, |
| 217 | key: line, |
| 218 | }) |
| 219 | .collect(), |
| 220 | secrets: secrets_json, |
| 221 | }) |
| 222 | .into_response() |
| 223 | } |
| 224 | |
| 225 | #[derive(Deserialize)] |
| 226 | struct PutSecret { |
| 227 | name: String, |
| 228 | envelope: serde_json::Value, |
| 229 | } |
| 230 | |
| 231 | /// `POST /{owner}/{repo}/-/api/secrets` — store a sealed envelope under a name, |
| 232 | /// replacing any previous value. The body is ciphertext; the server checks only |
| 233 | /// its shape. |
| 234 | async fn put_secret( |
| 235 | State(app): State<App>, |
| 236 | CurrentUser(user): CurrentUser, |
| 237 | csrf: Csrf, |
| 238 | Path((owner, repo)): Path<(String, String)>, |
| 239 | headers: HeaderMap, |
| 240 | Json(body): Json<PutSecret>, |
| 241 | ) -> Response { |
| 242 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 243 | Ok(m) => m, |
| 244 | Err(resp) => return resp, |
| 245 | }; |
| 246 | if !secrets::valid_name(&body.name) { |
| 247 | return bad_request("secret names are A–Z, 0–9 and _, and cannot start with a digit"); |
| 248 | } |
| 249 | let json = match serde_json::to_string(&body.envelope) { |
| 250 | Ok(j) => j, |
| 251 | Err(e) => return bad_request(e), |
| 252 | }; |
| 253 | let envelope = match Envelope::parse(&json) { |
| 254 | Ok(e) => e, |
| 255 | Err(e) => return bad_request(e), |
| 256 | }; |
| 257 | // A secret nobody can open is a footgun, not a feature: require it to be |
| 258 | // sealed to at least one key that is still registered. |
| 259 | let current = match recipients_for(&app, &meta).await { |
| 260 | Ok(r) => r, |
| 261 | Err(resp) => return resp, |
| 262 | }; |
| 263 | let sealed_to = envelope.recipient_fingerprints(); |
| 264 | if !current |
| 265 | .iter() |
| 266 | .any(|(r, _)| sealed_to.contains(&r.fingerprint)) |
| 267 | { |
| 268 | return bad_request("envelope is not sealed to any registered ssh key"); |
| 269 | } |
| 270 | match secrets::put(&app.db, meta.id, &body.name, &envelope).await { |
| 271 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 272 | Err(e) => bad_request(e), |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | /// `DELETE /{owner}/{repo}/-/api/secrets/{name}`. |
| 277 | async fn delete_secret( |
| 278 | State(app): State<App>, |
| 279 | CurrentUser(user): CurrentUser, |
| 280 | csrf: Csrf, |
| 281 | Path((owner, repo, name)): Path<(String, String, String)>, |
| 282 | headers: HeaderMap, |
| 283 | ) -> Response { |
| 284 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 285 | Ok(m) => m, |
| 286 | Err(resp) => return resp, |
| 287 | }; |
| 288 | match secrets::delete(&app.db, meta.id, &name).await { |
| 289 | Ok(()) => StatusCode::NO_CONTENT.into_response(), |
| 290 | Err(e) => server_error(e), |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | #[derive(Deserialize)] |
| 295 | struct UnlockBody { |
| 296 | values: std::collections::BTreeMap<String, String>, |
| 297 | #[serde(default)] |
| 298 | ttl_secs: i64, |
| 299 | } |
| 300 | |
| 301 | #[derive(Serialize)] |
| 302 | struct UnlockResponse { |
| 303 | unlocked_until: i64, |
| 304 | count: usize, |
| 305 | } |
| 306 | |
| 307 | /// `POST /{owner}/{repo}/-/api/secrets/unlock` — hand the server decrypted |
| 308 | /// values to hold in memory for CI until they expire. |
| 309 | /// |
| 310 | /// This is the *only* endpoint that sees plaintext, and the client must have |
| 311 | /// opened the envelopes itself to call it. Nothing is written to disk. |
| 312 | async fn unlock( |
| 313 | State(app): State<App>, |
| 314 | CurrentUser(user): CurrentUser, |
| 315 | csrf: Csrf, |
| 316 | Path((owner, repo)): Path<(String, String)>, |
| 317 | headers: HeaderMap, |
| 318 | Json(body): Json<UnlockBody>, |
| 319 | ) -> Response { |
| 320 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 321 | Ok(m) => m, |
| 322 | Err(resp) => return resp, |
| 323 | }; |
| 324 | for name in body.values.keys() { |
| 325 | if !secrets::valid_name(name) { |
| 326 | return bad_request(format!("invalid secret name `{name}`")); |
| 327 | } |
| 328 | } |
| 329 | let count = body.values.len(); |
| 330 | let ttl = if body.ttl_secs > 0 { |
| 331 | body.ttl_secs |
| 332 | } else { |
| 333 | 8 * 60 * 60 |
| 334 | }; |
| 335 | let unlocked_until = app.vault.unlock(meta.id, body.values, ttl); |
| 336 | tracing::info!("secrets: {owner}/{repo} unlocked with {count} value(s) until {unlocked_until}"); |
| 337 | Json(UnlockResponse { |
| 338 | unlocked_until, |
| 339 | count, |
| 340 | }) |
| 341 | .into_response() |
| 342 | } |
| 343 | |
| 344 | /// `POST /{owner}/{repo}/-/api/secrets/lock` — forget the values now. |
| 345 | async fn lock( |
| 346 | State(app): State<App>, |
| 347 | CurrentUser(user): CurrentUser, |
| 348 | csrf: Csrf, |
| 349 | Path((owner, repo)): Path<(String, String)>, |
| 350 | headers: HeaderMap, |
| 351 | ) -> Response { |
| 352 | let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await { |
| 353 | Ok(m) => m, |
| 354 | Err(resp) => return resp, |
| 355 | }; |
| 356 | app.vault.lock(meta.id); |
| 357 | StatusCode::NO_CONTENT.into_response() |
| 358 | } |
| 359 | |
| 360 | // --- form posts from the settings page ------------------------------------- |
| 361 | |
| 362 | async fn ui_delete( |
| 363 | State(app): State<App>, |
| 364 | CurrentUser(user): CurrentUser, |
| 365 | csrf: Csrf, |
| 366 | Path((owner, repo, name)): Path<(String, String, String)>, |
| 367 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 368 | ) -> Response { |
| 369 | let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await { |
| 370 | Ok(m) => m, |
| 371 | Err(resp) => return resp, |
| 372 | }; |
| 373 | if let Err(e) = secrets::delete(&app.db, meta.id, &name).await { |
| 374 | return server_error(e); |
| 375 | } |
| 376 | Redirect::to(&format!("/{owner}/{repo}/settings")).into_response() |
| 377 | } |
| 378 | |
| 379 | async fn ui_lock( |
| 380 | State(app): State<App>, |
| 381 | CurrentUser(user): CurrentUser, |
| 382 | csrf: Csrf, |
| 383 | Path((owner, repo)): Path<(String, String)>, |
| 384 | axum::Form(form): axum::Form<crate::auth::CsrfForm>, |
| 385 | ) -> Response { |
| 386 | let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await { |
| 387 | Ok(m) => m, |
| 388 | Err(resp) => return resp, |
| 389 | }; |
| 390 | app.vault.lock(meta.id); |
| 391 | Redirect::to(&format!("/{owner}/{repo}/settings")).into_response() |
| 392 | } |
| 393 | |
| 394 | async fn ui_authorize( |
| 395 | app: &App, |
| 396 | user: Option<User>, |
| 397 | csrf: &Csrf, |
| 398 | submitted: &str, |
| 399 | owner: &str, |
| 400 | repo: &str, |
| 401 | ) -> Result<Repository, Response> { |
| 402 | verify_csrf(csrf, submitted)?; |
| 403 | let meta = resolve(app, owner, repo).await?; |
| 404 | if !access::can_write(&meta, user.as_ref()) { |
| 405 | return Err((StatusCode::NOT_FOUND, "no such repository").into_response()); |
| 406 | } |
| 407 | Ok(meta) |
| 408 | } |
| 409 | |
| 410 | // --- shared helpers -------------------------------------------------------- |
| 411 | |
| 412 | fn split_fingerprints(csv: &str) -> Vec<String> { |
| 413 | csv.split(',') |
| 414 | .filter(|s| !s.is_empty()) |
| 415 | .map(str::to_string) |
| 416 | .collect() |
| 417 | } |
| 418 | |
| 419 | /// The repository owner's ssh-ed25519 keys, as (recipient, OpenSSH line). |
| 420 | /// Other key types are skipped: they cannot do X25519 key agreement. |
| 421 | async fn recipients_for( |
| 422 | app: &App, |
| 423 | meta: &Repository, |
| 424 | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { |
| 425 | let keys = ssh_keys::list_by_user(&app.db, meta.owner_id) |
| 426 | .await |
| 427 | .map_err(server_error)?; |
| 428 | Ok(keys |
| 429 | .into_iter() |
| 430 | .filter_map(|k| { |
| 431 | secrets::Recipient::from_openssh(&k.content) |
| 432 | .ok() |
| 433 | .map(|r| (r, k.content)) |
| 434 | }) |
| 435 | .collect()) |
| 436 | } |
| 437 | |
| 438 | /// Coarse countdown phrasing ("3 hours"), since [`fmt_relative`] only ever |
| 439 | /// looks backwards. |
| 440 | fn fmt_duration(secs: i64) -> String { |
| 441 | let plural = |n: i64, unit: &str| { |
| 442 | if n == 1 { |
| 443 | format!("1 {unit}") |
| 444 | } else { |
| 445 | format!("{n} {unit}s") |
| 446 | } |
| 447 | }; |
| 448 | match secs { |
| 449 | s if s <= 0 => "moments".to_string(), |
| 450 | s if s < 60 => plural(s, "second"), |
| 451 | s if s < 3600 => plural(s / 60, "minute"), |
| 452 | s if s < 86_400 => plural(s / 3600, "hour"), |
| 453 | s => plural(s / 86_400, "day"), |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /// The secrets section of a repository's settings page. |
| 458 | pub async fn settings_section(app: &App, owner: &str, repo: &str, meta: &Repository) -> Markup { |
| 459 | let recipients = recipients_for(app, meta).await.unwrap_or_default(); |
| 460 | let stored = secrets::list(&app.db, meta.id).await.unwrap_or_default(); |
| 461 | let status = app.vault.status(meta.id); |
| 462 | let csrf = crate::auth::current_csrf(); |
| 463 | |
| 464 | let recipients_json = serde_json::to_string( |
| 465 | &recipients |
| 466 | .iter() |
| 467 | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) |
| 468 | .collect::<Vec<_>>(), |
| 469 | ) |
| 470 | .unwrap_or_else(|_| "[]".to_string()); |
| 471 | let current: Vec<&str> = recipients |
| 472 | .iter() |
| 473 | .map(|(r, _)| r.fingerprint.as_str()) |
| 474 | .collect(); |
| 475 | |
| 476 | html! { |
| 477 | h2 style="margin-top:28px" { "Secrets" } |
| 478 | p.muted style="font-size:13px" { |
| 479 | "Encrypted in your browser to your ssh-ed25519 keys before they are sent. " |
| 480 | "anvil stores only the ciphertext and cannot read it — not here, not in a backup. " |
| 481 | "To let CI use them, run " |
| 482 | code { "anvild secret unlock " (owner) "/" (repo) } |
| 483 | " from a machine holding one of those keys." |
| 484 | } |
| 485 | |
| 486 | @if let Some(status) = status { |
| 487 | p.secret-unlocked { |
| 488 | "Unlocked for CI — " (status.count) " value(s), expires in " |
| 489 | (fmt_duration(status.expires_at - anvil_core::secrets::now_secs())) "." |
| 490 | form method="post" action=(format!("/{owner}/{repo}/-/secrets/lock")) style="display:inline;margin-left:8px" { |
| 491 | (csrf_input(&csrf)) |
| 492 | button.btn.btn-secondary type="submit" { "Lock now" } |
| 493 | } |
| 494 | } |
| 495 | } @else { |
| 496 | p.muted style="font-size:13px" { "Sealed: CI runs that declare secrets will fail until you unlock." } |
| 497 | } |
| 498 | |
| 499 | @if stored.is_empty() { |
| 500 | p.muted { "No secrets yet." } |
| 501 | } @else { |
| 502 | div.box { |
| 503 | @for s in &stored { |
| 504 | div.row { |
| 505 | span { |
| 506 | code { (s.name) } |
| 507 | @let sealed_to = split_fingerprints(&s.recipients); |
| 508 | @let missing = current.iter().filter(|fp| !sealed_to.iter().any(|s| s == **fp)).count(); |
| 509 | @if missing > 0 { |
| 510 | span.secret-stale title="Sealed before these keys were added" { |
| 511 | (missing) " key(s) cannot open this — rekey" |
| 512 | } |
| 513 | } |
| 514 | } |
| 515 | span.muted style="margin-left:auto;font-size:13px" { |
| 516 | "updated " (fmt_relative(s.updated_at)) |
| 517 | } |
| 518 | form method="post" style="margin-left:12px" |
| 519 | action=(format!("/{owner}/{repo}/-/secrets/{}/delete", s.name)) { |
| 520 | (csrf_input(&csrf)) |
| 521 | button.btn.btn-secondary type="submit" { "Delete" } |
| 522 | } |
| 523 | } |
| 524 | } |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | @if recipients.is_empty() { |
| 529 | p.secret-warn { |
| 530 | "No ssh-ed25519 key registered, so there is nothing to encrypt to. " |
| 531 | a href="/-/settings" { "Add one" } " first." |
| 532 | } |
| 533 | } @else { |
| 534 | // Deliberately not a <form>: with no form element there is no |
| 535 | // default submission path that could ever put a plaintext value in |
| 536 | // a request the browser builds by itself. |
| 537 | div #secrets-form.stack |
| 538 | data-repo=(format!("{owner}/{repo}")) |
| 539 | data-endpoint=(format!("/{owner}/{repo}/-/api/secrets")) |
| 540 | data-csrf=(csrf) |
| 541 | style="margin-top:16px" { |
| 542 | script #secret-recipients type="application/json" { (PreEscaped(recipients_json)) } |
| 543 | p { |
| 544 | label { "Name" br; input #secret-name type="text" placeholder="DEPLOY_TOKEN" autocomplete="off"; } |
| 545 | } |
| 546 | p { |
| 547 | label { "Value" br; textarea #secret-value rows="3" autocomplete="off" spellcheck="false" {} } |
| 548 | br; |
| 549 | span.muted style="font-size:12px" { |
| 550 | "Sealed to " (recipients.len()) " key(s) in this browser. The value never leaves the page in the clear." |
| 551 | } |
| 552 | } |
| 553 | p { |
| 554 | button.btn #secret-save type="button" { "Encrypt and save" } |
| 555 | span #secret-status.muted style="margin-left:10px;font-size:13px" {} |
| 556 | } |
| 557 | } |
| 558 | script { (PreEscaped(SEAL_JS)) } |
| 559 | script { (PreEscaped(FORM_JS)) } |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | /// Browser-side sealing, exposed as `anvilSealSecret(repo, name, value, |
| 565 | /// recipients)`. |
| 566 | /// |
| 567 | /// Mirrors [`anvil_core::secrets::seal`] exactly — same derivation, same |
| 568 | /// associated data, same field encoding — so the CLI can open what the browser |
| 569 | /// wrote and vice versa. `tests/js_interop.rs` runs this very string under node |
| 570 | /// and opens the result in Rust, which is what keeps the two halves honest. |
| 571 | /// |
| 572 | /// Every primitive is WebCrypto's; nothing here implements a cipher by hand. |
| 573 | /// The one piece of arithmetic is the Edwards → Montgomery map of the |
| 574 | /// recipient's public key, for which WebCrypto has no API. |
| 575 | pub const SEAL_JS: &str = r#" |
| 576 | globalThis.anvilSealSecret = (function () { |
| 577 | var te = new TextEncoder(); |
| 578 | |
| 579 | function b64(bytes) { |
| 580 | var s = ''; |
| 581 | for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); |
| 582 | return btoa(s); |
| 583 | } |
| 584 | |
| 585 | // An OpenSSH public-key line holds a base64 blob of length-prefixed fields: |
| 586 | // the algorithm name, then the 32-byte Ed25519 point. |
| 587 | function ed25519FromLine(line) { |
| 588 | var blob = Uint8Array.from(atob(line.trim().split(/\s+/)[1]), function (c) { return c.charCodeAt(0); }); |
| 589 | var off = 0; |
| 590 | function field() { |
| 591 | var n = (blob[off] << 24) | (blob[off + 1] << 16) | (blob[off + 2] << 8) | blob[off + 3]; |
| 592 | off += 4; |
| 593 | var out = blob.slice(off, off + n); |
| 594 | off += n; |
| 595 | return out; |
| 596 | } |
| 597 | if (new TextDecoder().decode(field()) !== 'ssh-ed25519') throw new Error('not an ssh-ed25519 key'); |
| 598 | var key = field(); |
| 599 | if (key.length !== 32) throw new Error('malformed ed25519 key'); |
| 600 | return key; |
| 601 | } |
| 602 | |
| 603 | // u = (1 + y) / (1 - y) mod 2^255-19: the birational map from the Edwards |
| 604 | // curve Ed25519 signs on to the Montgomery curve X25519 agrees on. |
| 605 | var P = (1n << 255n) - 19n; |
| 606 | function inverse(a) { |
| 607 | var result = 1n, base = ((a % P) + P) % P, e = P - 2n; |
| 608 | while (e > 0n) { |
| 609 | if (e & 1n) result = (result * base) % P; |
| 610 | base = (base * base) % P; |
| 611 | e >>= 1n; |
| 612 | } |
| 613 | return result; |
| 614 | } |
| 615 | function toMontgomery(ed) { |
| 616 | var b = Uint8Array.from(ed); |
| 617 | b[31] &= 0x7f; // drop the sign bit; only y matters |
| 618 | var y = 0n; |
| 619 | for (var i = 31; i >= 0; i--) y = (y << 8n) | BigInt(b[i]); |
| 620 | var den = ((1n - y) % P + P) % P; |
| 621 | if (den === 0n) throw new Error('degenerate key'); |
| 622 | var u = ((1n + y) % P) * inverse(den) % P; |
| 623 | var out = new Uint8Array(32); |
| 624 | for (var j = 0; j < 32; j++) { out[j] = Number(u & 0xffn); u >>= 8n; } |
| 625 | return out; |
| 626 | } |
| 627 | |
| 628 | async function aesEncrypt(key, nonce, aad, data) { |
| 629 | var k = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt']); |
| 630 | return new Uint8Array(await crypto.subtle.encrypt( |
| 631 | { name: 'AES-GCM', iv: nonce, additionalData: aad }, k, data)); |
| 632 | } |
| 633 | |
| 634 | return async function sealSecret(repo, name, value, recipients) { |
| 635 | var fileKey = crypto.getRandomValues(new Uint8Array(32)); |
| 636 | var nonce = crypto.getRandomValues(new Uint8Array(12)); |
| 637 | var aad = te.encode('anvil-secret-v1\n' + repo + '\n' + name); |
| 638 | var ct = await aesEncrypt(fileKey, nonce, aad, te.encode(value)); |
| 639 | |
| 640 | var stanzas = []; |
| 641 | for (var i = 0; i < recipients.length; i++) { |
| 642 | var r = recipients[i]; |
| 643 | var u = toMontgomery(ed25519FromLine(r.key)); |
| 644 | var pub = await crypto.subtle.importKey('raw', u, { name: 'X25519' }, false, []); |
| 645 | var eph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); |
| 646 | var epk = new Uint8Array(await crypto.subtle.exportKey('raw', eph.publicKey)); |
| 647 | var shared = new Uint8Array(await crypto.subtle.deriveBits( |
| 648 | { name: 'X25519', public: pub }, eph.privateKey, 256)); |
| 649 | var salt = new Uint8Array(64); |
| 650 | salt.set(epk, 0); |
| 651 | salt.set(u, 32); |
| 652 | var ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']); |
| 653 | var okm = new Uint8Array(await crypto.subtle.deriveBits( |
| 654 | { name: 'HKDF', hash: 'SHA-256', salt: salt, info: te.encode('anvil-secret-v1 wrap') }, |
| 655 | ikm, 256)); |
| 656 | var wrapNonce = crypto.getRandomValues(new Uint8Array(12)); |
| 657 | var wrapped = await aesEncrypt(okm, wrapNonce, te.encode(r.fingerprint), fileKey); |
| 658 | var wrap = new Uint8Array(12 + wrapped.length); |
| 659 | wrap.set(wrapNonce, 0); |
| 660 | wrap.set(wrapped, 12); |
| 661 | stanzas.push({ fp: r.fingerprint, epk: b64(epk), wrap: b64(wrap) }); |
| 662 | } |
| 663 | return { v: 1, alg: 'x25519-hkdf-sha256+aes256gcm', recipients: stanzas, nonce: b64(nonce), ct: b64(ct) }; |
| 664 | }; |
| 665 | })(); |
| 666 | "#; |
| 667 | |
| 668 | /// Wires the settings form to [`SEAL_JS`]: validate, seal, POST the envelope. |
| 669 | /// The plaintext lives in one textarea and is cleared as soon as the ciphertext |
| 670 | /// is on its way. |
| 671 | const FORM_JS: &str = r#" |
| 672 | (function () { |
| 673 | var root = document.getElementById('secrets-form'); |
| 674 | if (!root) return; |
| 675 | var nameEl = document.getElementById('secret-name'); |
| 676 | var valueEl = document.getElementById('secret-value'); |
| 677 | var button = document.getElementById('secret-save'); |
| 678 | var statusEl = document.getElementById('secret-status'); |
| 679 | var recipients = JSON.parse(document.getElementById('secret-recipients').textContent); |
| 680 | |
| 681 | function fail(message) { |
| 682 | statusEl.textContent = message; |
| 683 | statusEl.style.color = 'var(--error)'; |
| 684 | button.disabled = false; |
| 685 | } |
| 686 | |
| 687 | button.addEventListener('click', async function () { |
| 688 | var name = nameEl.value.trim(); |
| 689 | var value = valueEl.value; |
| 690 | statusEl.style.color = ''; |
| 691 | if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) return fail('Name must be A-Z, 0-9 and _, not starting with a digit.'); |
| 692 | if (!value) return fail('Value is empty.'); |
| 693 | if (!crypto.subtle || !window.BigInt) return fail('This browser cannot encrypt here; use `anvild secret set`.'); |
| 694 | |
| 695 | button.disabled = true; |
| 696 | statusEl.textContent = 'Encrypting…'; |
| 697 | var envelope; |
| 698 | try { |
| 699 | envelope = await anvilSealSecret(root.dataset.repo, name, value, recipients); |
| 700 | } catch (e) { |
| 701 | // Most likely cause: a browser without WebCrypto X25519. |
| 702 | return fail('Encryption failed (' + e.message + '). Use `anvild secret set` instead.'); |
| 703 | } |
| 704 | statusEl.textContent = 'Saving…'; |
| 705 | try { |
| 706 | var res = await fetch(root.dataset.endpoint, { |
| 707 | method: 'POST', |
| 708 | headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }, |
| 709 | body: JSON.stringify({ name: name, envelope: envelope }), |
| 710 | }); |
| 711 | if (!res.ok) return fail('Server rejected it: ' + (await res.text())); |
| 712 | } catch (e) { |
| 713 | return fail('Could not reach the server: ' + e.message); |
| 714 | } |
| 715 | // Clear the plaintext out of the DOM before the page goes away. |
| 716 | valueEl.value = ''; |
| 717 | nameEl.value = ''; |
| 718 | location.reload(); |
| 719 | }); |
| 720 | })(); |
| 721 | "#; |