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}
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.
92async 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
126async 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
139fn server_error(e: impl std::fmt::Display) -> Response {
140 tracing::error!("secrets: {e}");
141 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
142}
143
144fn 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)]
151struct 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)]
161struct 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)]
168struct 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.
178async 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)]
226struct 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.
234async 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}`.
277async 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)]
295struct UnlockBody {
296 values: std::collections::BTreeMap<String, String>,
297 #[serde(default)]
298 ttl_secs: i64,
299}
300
301#[derive(Serialize)]
302struct 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.
312async 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.
345async 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
362async 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
379async 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
394async 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
412fn 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.
421async 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.
440fn 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.
458pub 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.
575pub const SEAL_JS: &str = r#"
576globalThis.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.
671const 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"#;