anvilsign in

collin/anvil

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
12use 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};
25use 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};
46use maud::{
47 Markup,
48 PreEscaped,
49 html,
50};
51use serde::{
52 Deserialize,
53 Serialize,
54};
55
56use 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
69pub 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.
105async 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
139async 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
152fn server_error(e: impl std::fmt::Display) -> Response {
153 tracing::error!("secrets: {e}");
154 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
155}
156
157fn 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)]
164struct 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)]
174struct 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)]
181struct 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.
191async 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)]
239struct 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.
247async 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}`.
290async 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)]
308struct UnlockBody {
309 values: std::collections::BTreeMap<String, String>,
310 #[serde(default)]
311 ttl_secs: i64,
312}
313
314#[derive(Serialize)]
315struct 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.
325async 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.
358async 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
375async 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
392async 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
407async 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.
431async 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)]
457struct UserSecretsResponse {
458 account: String,
459 unlocked_until: i64,
460 recipients: Vec<RecipientJson>,
461 secrets: Vec<UserSecretJson>,
462}
463
464#[derive(Serialize)]
465struct 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`.
476async 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)]
526struct 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`).
541async 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}`.
593async 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`].
612async 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.
647async 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
663async 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
682async 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.
700async 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
719fn 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.
728async 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.
747fn 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.
765pub 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`.
878pub 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.
1014pub const SEAL_JS: &str = r#"
1015globalThis.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.
1110const 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.
1168const 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"#;