1use std::net::SocketAddr;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::Mutex;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::time::{Duration, Instant};
31
32use axum::{
33 Json, Router,
34 extract::{Query, Request, State},
35 http::{HeaderValue, Method, StatusCode, header},
36 middleware::{self, Next},
37 response::{Html, IntoResponse, Response},
38 routing::{get, post},
39};
40use kovra_core::{
41 AccessRequest, AgentScope, AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome,
42 ConfirmRequest, Confirmer, Coordinate, Decision, FileAuditSink, IntakeBroker, MasterKey,
43 Operation, Origin, Registry, Resolution, SecretRecord, SecretValue, Sensitivity, Surface,
44 SystemClock, birth_sensitivity, decide, delete_requires_confirmation,
45 downgrade_requires_confirmation, fingerprint, is_downgrade, store,
46};
47use rand::RngCore;
48use serde::Deserialize;
49use serde_json::{Value, json};
50use std::str::FromStr;
51
52mod assets;
53
54pub const SESSION_HEADER: &str = "x-kovra-session";
56
57pub const DEFAULT_PORT: u16 = 8731;
59
60#[derive(Clone)]
64pub struct AppState {
65 inner: Arc<Inner>,
66}
67
68struct Inner {
69 root: PathBuf,
70 master: MasterKey,
71 session_token: String,
72 last_activity: Mutex<Instant>,
73 confirmer: Arc<dyn Confirmer + Send + Sync>,
79 locked: AtomicBool,
84}
85
86impl AppState {
87 pub fn new(
92 root: PathBuf,
93 master: MasterKey,
94 confirmer: Arc<dyn Confirmer + Send + Sync>,
95 ) -> Self {
96 let mut buf = [0u8; 16];
97 rand::rngs::OsRng.fill_bytes(&mut buf);
98 let session_token = buf.iter().map(|b| format!("{b:02x}")).collect();
99 Self::new_with_session(root, master, session_token, confirmer)
100 }
101
102 pub fn new_with_session(
107 root: PathBuf,
108 master: MasterKey,
109 session_token: String,
110 confirmer: Arc<dyn Confirmer + Send + Sync>,
111 ) -> Self {
112 Self {
113 inner: Arc::new(Inner {
114 root,
115 master,
116 session_token,
117 last_activity: Mutex::new(Instant::now()),
118 confirmer,
119 locked: AtomicBool::new(false),
120 }),
121 }
122 }
123
124 pub fn session_token(&self) -> &str {
126 &self.inner.session_token
127 }
128
129 fn confirmer(&self) -> Arc<dyn Confirmer + Send + Sync> {
131 Arc::clone(&self.inner.confirmer)
132 }
133
134 fn registry(&self) -> Result<Registry, AppError> {
135 Registry::open(&self.inner.root).map_err(|e| AppError::internal(e.to_string()))
136 }
137
138 fn root(&self) -> &std::path::Path {
140 &self.inner.root
141 }
142
143 fn key(&self) -> &[u8; kovra_core::KEY_LEN] {
144 self.inner.master.expose()
145 }
146
147 fn audit(&self, action: AuditAction, result: &str, canonical: &str, env: &str) {
148 let clock = SystemClock;
149 let _ = FileAuditSink::under_root(&self.inner.root).record(
150 &AuditEvent::new(&clock, action, result)
151 .at(canonical, env)
152 .by(Origin::Human),
153 );
154 }
155
156 fn touch(&self) {
157 if let Ok(mut t) = self.inner.last_activity.lock() {
158 *t = Instant::now();
159 }
160 }
161
162 fn idle_for(&self) -> Duration {
163 self.inner
164 .last_activity
165 .lock()
166 .map(|t| t.elapsed())
167 .unwrap_or_default()
168 }
169
170 pub fn lock(&self) {
175 self.inner.locked.store(true, Ordering::SeqCst);
176 }
177
178 pub fn unlock(&self) {
180 self.inner.locked.store(false, Ordering::SeqCst);
181 }
182
183 pub fn is_locked(&self) -> bool {
185 self.inner.locked.load(Ordering::SeqCst)
186 }
187}
188
189#[derive(Debug)]
191struct AppError {
192 status: StatusCode,
193 message: String,
194}
195
196impl AppError {
197 fn new(status: StatusCode, message: impl Into<String>) -> Self {
198 Self {
199 status,
200 message: message.into(),
201 }
202 }
203 fn internal(message: impl Into<String>) -> Self {
204 Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
205 }
206 fn bad(message: impl Into<String>) -> Self {
207 Self::new(StatusCode::BAD_REQUEST, message)
208 }
209 fn not_found(message: impl Into<String>) -> Self {
210 Self::new(StatusCode::NOT_FOUND, message)
211 }
212}
213
214impl IntoResponse for AppError {
215 fn into_response(self) -> Response {
216 (self.status, Json(json!({ "error": self.message }))).into_response()
217 }
218}
219
220pub fn build_app(state: AppState) -> Router {
224 let guarded = Router::new()
227 .route("/secrets", get(list_secrets))
228 .route("/reveal", get(reveal_secret))
229 .route(
230 "/secret",
231 post(create_secret)
232 .put(update_value)
233 .patch(edit_metadata)
234 .delete(delete_secret),
235 )
236 .route("/generate", post(generate_secret))
237 .route("/intakes", get(list_intakes).delete(dismiss_intake))
240 .route("/intakes/fulfill", post(fulfill_intake))
241 .route_layer(middleware::from_fn_with_state(state.clone(), lock_guard));
242
243 let api = guarded
246 .route("/lock", post(lock_ui))
247 .route("/unlock", post(unlock_ui))
248 .route_layer(middleware::from_fn_with_state(
249 state.clone(),
250 require_session,
251 ));
252
253 Router::new()
254 .route("/", get(index))
255 .merge(assets::routes())
259 .nest("/api", api)
260 .layer(middleware::from_fn_with_state(
261 state.clone(),
262 loopback_guard,
263 ))
264 .layer(middleware::from_fn(security_headers))
267 .with_state(state)
268}
269
270const CSP: &str = "default-src 'none'; \
278script-src 'self'; \
279style-src 'self' 'unsafe-inline'; \
280img-src 'self' data:; \
281font-src 'self'; \
282connect-src 'self'; \
283base-uri 'none'; \
284form-action 'none'; \
285frame-ancestors 'none'";
286
287async fn security_headers(req: Request, next: Next) -> Response {
292 let mut res = next.run(req).await;
293 let h = res.headers_mut();
294 h.insert(
295 header::CONTENT_SECURITY_POLICY,
296 HeaderValue::from_static(CSP),
297 );
298 h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
299 h.insert(
300 header::REFERRER_POLICY,
301 HeaderValue::from_static("no-referrer"),
302 );
303 h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
304 res
305}
306
307async fn loopback_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
313 if let Some(host) = req
314 .headers()
315 .get(header::HOST)
316 .and_then(|h| h.to_str().ok())
317 && !is_loopback_host(host)
318 {
319 return AppError::new(StatusCode::FORBIDDEN, "non-loopback Host rejected (I10)")
320 .into_response();
321 }
322 if let Some(origin) = req
324 .headers()
325 .get(header::ORIGIN)
326 .and_then(|h| h.to_str().ok())
327 && !is_loopback_origin(origin)
328 {
329 return AppError::new(StatusCode::FORBIDDEN, "cross-origin request rejected")
330 .into_response();
331 }
332 if is_state_changing(req.method()) {
340 let origin_ok = req
341 .headers()
342 .get(header::ORIGIN)
343 .and_then(|h| h.to_str().ok())
344 .is_some_and(is_loopback_origin);
345 if !origin_ok {
346 return AppError::new(
347 StatusCode::FORBIDDEN,
348 "state-changing request requires a loopback Origin",
349 )
350 .into_response();
351 }
352 }
353 state.touch();
354 next.run(req).await
355}
356
357fn is_state_changing(method: &Method) -> bool {
360 matches!(
361 *method,
362 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
363 )
364}
365
366async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
369 let presented = req
370 .headers()
371 .get(SESSION_HEADER)
372 .and_then(|h| h.to_str().ok())
373 .unwrap_or_default();
374 if presented.is_empty() || presented != state.session_token() {
377 return AppError::new(StatusCode::UNAUTHORIZED, "missing or invalid session token")
378 .into_response();
379 }
380 next.run(req).await
381}
382
383async fn lock_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
387 if state.is_locked() {
388 return AppError::new(
389 StatusCode::LOCKED,
390 "the UI is locked — POST /api/unlock to re-authenticate",
391 )
392 .into_response();
393 }
394 next.run(req).await
395}
396
397async fn lock_ui(State(state): State<AppState>) -> Response {
402 state.lock();
403 (StatusCode::OK, Json(json!({ "locked": true }))).into_response()
404}
405
406async fn unlock_ui(State(state): State<AppState>) -> Response {
410 let req = ConfirmRequest::for_action("Unlock the kovra Web UI", Origin::Human)
411 .with_requesting_process("kovra ui (web admin)");
412 match confirm_action(state.confirmer(), req).await {
413 ConfirmOutcome::Approved => {
414 state.unlock();
415 (StatusCode::OK, Json(json!({ "locked": false }))).into_response()
416 }
417 ConfirmOutcome::Denied => {
418 AppError::new(StatusCode::FORBIDDEN, "unlock denied").into_response()
419 }
420 ConfirmOutcome::TimedOut => {
421 AppError::new(StatusCode::REQUEST_TIMEOUT, "unlock timed out").into_response()
422 }
423 }
424}
425
426fn is_loopback_host(host: &str) -> bool {
427 let h = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
432 h == "127.0.0.1" || h == "[::1]" || h == "::1"
433}
434
435fn is_loopback_origin(origin: &str) -> bool {
436 let rest = match origin.strip_prefix("http://") {
437 Some(r) => r,
438 None => match origin.strip_prefix("https://") {
439 Some(r) => r,
440 None => return false,
441 },
442 };
443 is_loopback_host(rest)
444}
445
446#[derive(Deserialize, Default)]
449struct ScopeQuery {
450 project: Option<String>,
451}
452
453#[derive(Deserialize)]
454struct CoordQuery {
455 coord: String,
456 project: Option<String>,
457}
458
459async fn index() -> Html<String> {
462 Html(INDEX_HTML.replace("__KOVRA_VERSION__", env!("CARGO_PKG_VERSION")))
465}
466
467async fn list_secrets(
471 State(state): State<AppState>,
472 Query(q): Query<ScopeQuery>,
473) -> Result<Json<Value>, AppError> {
474 let registry = state.registry()?;
475 let mut rows: Vec<Value> = Vec::new();
476 let mut global_coords: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
477
478 let mut collect = |dir: PathBuf, origin: String| -> Result<(), AppError> {
479 let outcome =
480 store::load_all(&dir, state.key()).map_err(|e| AppError::internal(e.to_string()))?;
481 for (_, record) in outcome.records {
482 if origin == "global" {
483 global_coords.insert(record.canonical_path());
484 }
485 rows.push(row_for(&record, &origin));
486 }
487 Ok(())
488 };
489
490 match q.project.as_deref() {
491 Some(p) => collect(registry.project_dir(p), format!("project:{p}"))?,
492 None => {
493 collect(registry.global_dir(), "global".to_string())?;
494 for name in registry
495 .list_projects()
496 .map_err(|e| AppError::internal(e.to_string()))?
497 {
498 collect(registry.project_dir(&name), format!("project:{name}"))?;
499 }
500 }
501 }
502
503 for row in &mut rows {
505 let is_project = row
506 .get("origin")
507 .and_then(|o| o.as_str())
508 .is_some_and(|o| o.starts_with("project:"));
509 let coord = row.get("coordinate").and_then(|c| c.as_str()).unwrap_or("");
510 if is_project && global_coords.contains(coord) {
511 row["shadows_global"] = json!(true);
512 }
513 }
514
515 Ok(Json(json!({ "secrets": rows })))
516}
517
518fn row_for(record: &SecretRecord, origin: &str) -> Value {
522 let base = json!({
523 "origin": origin,
524 "coordinate": record.canonical_path(),
525 "environment": record.environment(),
526 "component": record.component(),
527 "key": record.key(),
528 "sensitivity": sensitivity_str(record.sensitivity()),
529 "revealable": record.revealable(),
530 "shadows_global": false,
531 "created": record.created(),
532 "updated": record.updated(),
533 });
534 let mut v = base;
535 match record {
536 SecretRecord::Literal { value, .. } => {
537 v["mode"] = json!("literal");
538 v["fingerprint"] = json!(fingerprint(value.expose()));
539 }
540 SecretRecord::Reference { reference, .. } => {
541 v["mode"] = json!("reference");
542 v["pointer"] = json!(reference);
543 }
544 SecretRecord::Keypair {
545 algorithm,
546 private,
547 public,
548 ..
549 } => {
550 v["mode"] = json!(if private.is_some() {
551 "keypair"
552 } else {
553 "public-only"
554 });
555 v["algorithm"] = json!(algorithm.as_str());
556 v["public"] = json!(public); v["fingerprint"] = json!(fingerprint(public.as_bytes()));
558 }
559 SecretRecord::Totp {
560 algorithm,
561 digits,
562 period,
563 ..
564 } => {
565 v["mode"] = json!("totp");
566 v["algorithm"] = json!(algorithm.as_str());
567 v["digits"] = json!(digits);
568 v["period"] = json!(period);
569 }
570 }
571 v
572}
573
574async fn reveal_secret(
579 State(state): State<AppState>,
580 Query(q): Query<CoordQuery>,
581) -> Result<Json<Value>, AppError> {
582 let coord = parse_coord(&q.coord)?;
583 let registry = state.registry()?;
584 let record = match registry
585 .resolve_with_key(&coord, q.project.as_deref(), state.key())
586 .map_err(|e| AppError::internal(e.to_string()))?
587 {
588 Resolution::Found { record, origin } => {
589 let _ = origin; record
591 }
592 Resolution::NotFound => {
593 return Err(AppError::not_found(format!("no secret at `{}`", q.coord)));
594 }
595 };
596 let canonical = record.canonical_path();
597 let env = record.environment().to_string();
598 let sensitivity = record.sensitivity();
599
600 match &record {
602 SecretRecord::Reference { reference, .. } => {
603 return Ok(Json(json!({
604 "coordinate": canonical,
605 "kind": "reference",
606 "pointer": reference,
607 "status": "unverified",
608 "note": "value not stored; materialized at run time by the provider (I8)"
609 })));
610 }
611 SecretRecord::Keypair {
612 algorithm,
613 private,
614 public,
615 ..
616 } => {
617 return Ok(Json(json!({
618 "coordinate": canonical,
619 "kind": if private.is_some() { "keypair" } else { "public-only" },
620 "algorithm": algorithm.as_str(),
621 "public": public,
622 "note": "private half is custodied; use the CLI (sign/decrypt/ssh-add)"
623 })));
624 }
625 SecretRecord::Totp {
626 algorithm,
627 digits,
628 period,
629 ..
630 } => {
631 return Ok(Json(json!({
632 "coordinate": canonical,
633 "kind": "totp",
634 "algorithm": algorithm.as_str(),
635 "digits": digits,
636 "period": period,
637 "note": "seed is custodied; derive a code with the CLI (`kovra code`)"
638 })));
639 }
640 SecretRecord::Literal { .. } => {}
641 }
642
643 let SecretRecord::Literal {
644 value, revealable, ..
645 } = &record
646 else {
647 unreachable!("non-literal handled above");
648 };
649
650 let request = AccessRequest {
651 coordinate: &coord,
652 project: q.project.as_deref(),
653 sensitivity,
654 revealable: *revealable,
655 operation: Operation::Reveal,
656 surface: Surface::WebUi,
657 origin: Origin::Human,
658 };
659 match decide(&request, &AgentScope::full()) {
660 Decision::Allow => {
661 let value_str = String::from_utf8_lossy(value.expose()).into_owned();
664 state.audit(AuditAction::Reveal, "revealed", &canonical, &env);
665 Ok(Json(json!({
666 "coordinate": canonical,
667 "kind": "literal",
668 "sensitivity": sensitivity_str(sensitivity),
669 "value": value_str
670 })))
671 }
672 Decision::Deny(reason) => {
673 use kovra_core::DenyReason;
676 let body = match reason {
677 DenyReason::WebUiCriticalMasked => json!({
678 "coordinate": canonical,
679 "kind": "literal",
680 "sensitivity": sensitivity_str(sensitivity),
681 "masked": true,
682 "fingerprint": fingerprint(value.expose()),
683 "note": "high — masked in the browser (I1); reveal via the CLI's biometric channel"
684 }),
685 DenyReason::InjectOnlyNeverRevealed => json!({
686 "coordinate": canonical,
687 "kind": "literal",
688 "sensitivity": sensitivity_str(sensitivity),
689 "inject_only": true,
690 "note": "inject-only — never revealed on any surface (I2)"
691 }),
692 other => json!({
693 "coordinate": canonical,
694 "kind": "literal",
695 "masked": true,
696 "note": format!("not revealable here: {other:?}")
697 }),
698 };
699 state.audit(AuditAction::Reveal, "masked", &canonical, &env);
700 Ok(Json(body))
701 }
702 Decision::Unaddressable => Err(AppError::not_found("not addressable")),
703 Decision::RequireConfirmation => {
704 Ok(Json(json!({
707 "coordinate": canonical,
708 "kind": "literal",
709 "masked": true,
710 "fingerprint": fingerprint(value.expose()),
711 "note": "requires confirmation — reveal via the CLI"
712 })))
713 }
714 }
715}
716
717#[derive(Deserialize)]
718struct CreateBody {
719 coord: String,
720 project: Option<String>,
721 value: Option<String>,
722 reference: Option<String>,
723 sensitivity: Option<String>,
724 description: Option<String>,
725 #[serde(default)]
726 revealable: bool,
727}
728
729async fn create_secret(
732 State(state): State<AppState>,
733 Json(body): Json<CreateBody>,
734) -> Result<Json<Value>, AppError> {
735 let coord = parse_coord(&body.coord)?;
736 let (env, component, key) = segments(&coord);
737 let registry = state.registry()?;
738 let dir = vault_dir(®istry, body.project.as_deref());
739
740 if store::read_record(&dir, &coord, state.key())
741 .map_err(|e| AppError::internal(e.to_string()))?
742 .is_some()
743 {
744 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
745 }
746 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
747 let born = birth_sensitivity(&env, chosen);
748 let now = SystemClock.now_rfc3339();
749 let record = match (&body.reference, &body.value) {
750 (Some(reference), _) => SecretRecord::Reference {
751 reference: reference.clone(),
752 sensitivity: born,
753 revealable: body.revealable,
754 environment: env.clone(),
755 component,
756 key,
757 description: body.description.clone(),
758 created: now.clone(),
759 updated: now,
760 },
761 (None, Some(value)) => SecretRecord::Literal {
762 value: SecretValue::from(value.as_str()),
763 sensitivity: born,
764 revealable: body.revealable,
765 environment: env.clone(),
766 component,
767 key,
768 description: body.description.clone(),
769 created: now.clone(),
770 updated: now,
771 },
772 (None, None) => return Err(AppError::bad("provide `value` or `reference`")),
773 };
774 write(&dir, &coord, &record, state.key())?;
775 state.audit(
776 AuditAction::Create,
777 "created",
778 &record.canonical_path(),
779 &env,
780 );
781 Ok(Json(
782 json!({ "created": record.canonical_path(), "sensitivity": sensitivity_str(born) }),
783 ))
784}
785
786#[derive(Deserialize)]
787struct UpdateBody {
788 coord: String,
789 project: Option<String>,
790 value: String,
791}
792
793async fn update_value(
796 State(state): State<AppState>,
797 Json(body): Json<UpdateBody>,
798) -> Result<Json<Value>, AppError> {
799 let coord = parse_coord(&body.coord)?;
800 let registry = state.registry()?;
801 let dir = vault_dir(®istry, body.project.as_deref());
802 let existing = store::read_record(&dir, &coord, state.key())
803 .map_err(|e| AppError::internal(e.to_string()))?
804 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
805 let now = SystemClock.now_rfc3339();
806 let record = match existing {
807 SecretRecord::Literal {
808 sensitivity,
809 revealable,
810 environment,
811 component,
812 key,
813 description,
814 created,
815 ..
816 } => SecretRecord::Literal {
817 value: SecretValue::from(body.value.as_str()),
818 sensitivity,
819 revealable,
820 environment,
821 component,
822 key,
823 description,
824 created,
825 updated: now,
826 },
827 _ => return Err(AppError::bad("only a literal's value can be updated here")),
828 };
829 write(&dir, &coord, &record, state.key())?;
830 state.audit(
831 AuditAction::Edit,
832 "value-updated",
833 &record.canonical_path(),
834 record.environment(),
835 );
836 Ok(Json(json!({ "updated": record.canonical_path() })))
837}
838
839#[derive(Deserialize)]
840struct EditBody {
841 coord: String,
842 project: Option<String>,
843 sensitivity: Option<String>,
844 description: Option<String>,
845 reference: Option<String>,
846 revealable: Option<bool>,
847}
848
849async fn edit_metadata(
852 State(state): State<AppState>,
853 Json(body): Json<EditBody>,
854) -> Result<Json<Value>, AppError> {
855 let coord = parse_coord(&body.coord)?;
856 let registry = state.registry()?;
857 let dir = vault_dir(®istry, body.project.as_deref());
858 let existing = store::read_record(&dir, &coord, state.key())
859 .map_err(|e| AppError::internal(e.to_string()))?
860 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
861 let new_sensitivity = parse_sensitivity(body.sensitivity.as_deref())?;
862 let env = existing.environment().to_string();
863 let lowered = matches!(new_sensitivity, Some(s) if is_downgrade(existing.sensitivity(), s));
864
865 if let Some(new) = new_sensitivity
870 && downgrade_requires_confirmation(existing.sensitivity(), new)
871 {
872 let canonical = existing.canonical_path();
873 let req = ui_action_request(
874 &existing,
875 format!(
876 "edit {canonical} --sensitivity {} (downgrade, web ui)",
877 sensitivity_str(new)
878 ),
879 );
880 match confirm_action(state.confirmer(), req).await {
881 ConfirmOutcome::Approved => {
882 state.audit(AuditAction::Approve, "approved-downgrade", &canonical, &env);
883 }
884 ConfirmOutcome::Denied => {
885 state.audit(AuditAction::Deny, "denied-downgrade", &canonical, &env);
886 return Err(AppError::new(
887 StatusCode::FORBIDDEN,
888 "denied — sensitivity not lowered",
889 ));
890 }
891 ConfirmOutcome::TimedOut => {
892 state.audit(AuditAction::Timeout, "timeout-downgrade", &canonical, &env);
893 return Err(AppError::new(
894 StatusCode::REQUEST_TIMEOUT,
895 "timed out — sensitivity not lowered",
896 ));
897 }
898 }
899 }
900
901 let now = SystemClock.now_rfc3339();
902 let updated = apply_edit(
903 existing,
904 new_sensitivity,
905 body.description.clone(),
906 body.reference.clone(),
907 body.revealable,
908 now,
909 )?;
910 write(&dir, &coord, &updated, state.key())?;
911 if lowered {
912 state.audit(
913 AuditAction::SensitivityDowngrade,
914 "downgraded",
915 &updated.canonical_path(),
916 &env,
917 );
918 }
919 state.audit(
920 AuditAction::Edit,
921 "metadata-updated",
922 &updated.canonical_path(),
923 &env,
924 );
925 Ok(Json(json!({ "edited": updated.canonical_path() })))
926}
927
928async fn delete_secret(
930 State(state): State<AppState>,
931 Query(q): Query<CoordQuery>,
932) -> Result<Json<Value>, AppError> {
933 let coord = parse_coord(&q.coord)?;
934 let registry = state.registry()?;
935 let dir = vault_dir(®istry, q.project.as_deref());
936 let existing = store::read_record(&dir, &coord, state.key())
937 .map_err(|e| AppError::internal(e.to_string()))?
938 .ok_or_else(|| AppError::not_found(format!("`{}` not found", q.coord)))?;
939 let canonical = existing.canonical_path();
940 let env = existing.environment().to_string();
941
942 if delete_requires_confirmation(existing.sensitivity()) {
950 let req = ui_action_request(&existing, format!("delete {canonical} (web ui)"));
951 match confirm_action(state.confirmer(), req).await {
952 ConfirmOutcome::Approved => {
953 state.audit(AuditAction::Approve, "approved-delete", &canonical, &env);
954 }
955 ConfirmOutcome::Denied => {
956 state.audit(AuditAction::Deny, "denied-delete", &canonical, &env);
957 return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not deleted"));
958 }
959 ConfirmOutcome::TimedOut => {
960 state.audit(AuditAction::Timeout, "timeout-delete", &canonical, &env);
961 return Err(AppError::new(
962 StatusCode::REQUEST_TIMEOUT,
963 "timed out — not deleted",
964 ));
965 }
966 }
967 }
968
969 store::delete_record(&dir, &coord).map_err(|e| AppError::internal(e.to_string()))?;
970 state.audit(AuditAction::Delete, "deleted", &canonical, &env);
971 Ok(Json(json!({ "deleted": canonical })))
972}
973
974#[derive(Deserialize)]
975struct GenerateBody {
976 coord: String,
977 project: Option<String>,
978 length: Option<usize>,
979 sensitivity: Option<String>,
980 description: Option<String>,
981}
982
983async fn generate_secret(
986 State(state): State<AppState>,
987 Json(body): Json<GenerateBody>,
988) -> Result<Json<Value>, AppError> {
989 let coord = parse_coord(&body.coord)?;
990 let (env, component, key) = segments(&coord);
991 let registry = state.registry()?;
992 let dir = vault_dir(®istry, body.project.as_deref());
993 if store::read_record(&dir, &coord, state.key())
994 .map_err(|e| AppError::internal(e.to_string()))?
995 .is_some()
996 {
997 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
998 }
999 let length = body.length.unwrap_or(32);
1000 if length == 0 {
1001 return Err(AppError::bad("length must be at least 1"));
1002 }
1003 use rand::Rng;
1004 use rand::distributions::Alphanumeric;
1005 let generated: String = rand::rngs::OsRng
1006 .sample_iter(&Alphanumeric)
1007 .take(length)
1008 .map(char::from)
1009 .collect();
1010 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
1011 let born = birth_sensitivity(&env, chosen);
1012 let now = SystemClock.now_rfc3339();
1013 let record = SecretRecord::Literal {
1014 value: SecretValue::from(generated),
1015 sensitivity: born,
1016 revealable: false,
1017 environment: env.clone(),
1018 component,
1019 key,
1020 description: body.description.clone(),
1021 created: now.clone(),
1022 updated: now,
1023 };
1024 write(&dir, &coord, &record, state.key())?;
1025 state.audit(
1026 AuditAction::Create,
1027 "generated",
1028 &record.canonical_path(),
1029 &env,
1030 );
1031 Ok(Json(json!({
1032 "generated": record.canonical_path(),
1033 "length": length,
1034 "sensitivity": sensitivity_str(born),
1035 "note": "value stored, never returned"
1036 })))
1037}
1038
1039async fn list_intakes(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
1043 let broker = IntakeBroker::under_root(state.root());
1044 let pending = broker
1045 .list_pending()
1046 .map_err(|e| AppError::internal(e.to_string()))?;
1047 let rows: Vec<Value> = pending
1048 .iter()
1049 .map(|i| {
1050 json!({
1051 "id": i.id,
1052 "coordinate": i.coordinate,
1053 "sensitivity": sensitivity_str(i.sensitivity),
1054 "environment": i.environment,
1055 "origin": format!("{:?}", i.origin).to_lowercase(),
1056 "requesting_process": i.requesting_process,
1057 "description": i.description.as_ref().map(|d| d.0.clone()),
1060 "created_unix": i.created_unix,
1061 })
1062 })
1063 .collect();
1064 Ok(Json(json!({ "intakes": rows })))
1065}
1066
1067#[derive(Deserialize)]
1068struct FulfillBody {
1069 id: String,
1070 value: String,
1071 project: Option<String>,
1072}
1073
1074async fn fulfill_intake(
1081 State(state): State<AppState>,
1082 Json(body): Json<FulfillBody>,
1083) -> Result<Json<Value>, AppError> {
1084 let broker = IntakeBroker::under_root(state.root());
1085 let intake = broker
1086 .get(&body.id)
1087 .map_err(|e| AppError::internal(e.to_string()))?
1088 .ok_or_else(|| AppError::not_found(format!("no pending intake `{}`", body.id)))?;
1089 if body.value.is_empty() {
1090 return Err(AppError::bad("value must not be empty"));
1091 }
1092 let coord = parse_coord(&intake.coordinate)?;
1093 let (env, component, key) = segments(&coord);
1094 let registry = state.registry()?;
1095 let dir = vault_dir(®istry, body.project.as_deref());
1096 if store::read_record(&dir, &coord, state.key())
1097 .map_err(|e| AppError::internal(e.to_string()))?
1098 .is_some()
1099 {
1100 return Err(AppError::bad(format!(
1101 "`{}` already exists",
1102 intake.coordinate
1103 )));
1104 }
1105 let born = birth_sensitivity(&env, intake.sensitivity); if born == Sensitivity::High {
1112 let mut req =
1113 ConfirmRequest::new(intake.coordinate.clone(), born, env.clone(), Origin::Human)
1114 .with_command(format!("fulfil {} (web ui)", intake.coordinate))
1115 .with_requesting_process("kovra ui (web admin)");
1116 if let Some(d) = &intake.description {
1117 req = req.with_requester_description(d.0.clone());
1118 }
1119 match confirm_action(state.confirmer(), req).await {
1120 ConfirmOutcome::Approved => {
1121 state.audit(
1122 AuditAction::Approve,
1123 "approved-intake",
1124 &intake.coordinate,
1125 &env,
1126 );
1127 }
1128 ConfirmOutcome::Denied => {
1129 state.audit(AuditAction::Deny, "denied-intake", &intake.coordinate, &env);
1130 return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not created"));
1131 }
1132 ConfirmOutcome::TimedOut => {
1133 state.audit(
1134 AuditAction::Timeout,
1135 "timeout-intake",
1136 &intake.coordinate,
1137 &env,
1138 );
1139 return Err(AppError::new(
1140 StatusCode::REQUEST_TIMEOUT,
1141 "timed out — not created",
1142 ));
1143 }
1144 }
1145 }
1146
1147 let now = SystemClock.now_rfc3339();
1148 let record = SecretRecord::Literal {
1149 value: SecretValue::from(body.value.as_str()),
1150 sensitivity: born,
1151 revealable: false,
1152 environment: env.clone(),
1153 component,
1154 key,
1155 description: None,
1156 created: now.clone(),
1157 updated: now,
1158 };
1159 write(&dir, &coord, &record, state.key())?;
1160 broker
1161 .cancel(&body.id)
1162 .map_err(|e| AppError::internal(e.to_string()))?;
1163 state.audit(
1164 AuditAction::Create,
1165 "fulfilled-intake",
1166 &record.canonical_path(),
1167 &env,
1168 );
1169 Ok(Json(json!({
1170 "fulfilled": record.canonical_path(),
1171 "sensitivity": sensitivity_str(born),
1172 })))
1173}
1174
1175#[derive(Deserialize)]
1176struct IdQuery {
1177 id: String,
1178}
1179
1180async fn dismiss_intake(
1184 State(state): State<AppState>,
1185 Query(q): Query<IdQuery>,
1186) -> Result<Json<Value>, AppError> {
1187 let broker = IntakeBroker::under_root(state.root());
1188 broker
1189 .cancel(&q.id)
1190 .map_err(|e| AppError::internal(e.to_string()))?;
1191 Ok(Json(json!({ "dismissed": q.id })))
1192}
1193
1194const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120);
1199
1200async fn confirm_action(
1205 confirmer: Arc<dyn Confirmer + Send + Sync>,
1206 req: ConfirmRequest,
1207) -> ConfirmOutcome {
1208 tokio::task::spawn_blocking(move || confirmer.confirm(&req, CONFIRM_TIMEOUT))
1209 .await
1210 .unwrap_or(ConfirmOutcome::Denied)
1211}
1212
1213fn ui_action_request(record: &SecretRecord, command: String) -> ConfirmRequest {
1218 ConfirmRequest::new(
1219 record.canonical_path(),
1220 record.sensitivity(),
1221 record.environment().to_string(),
1222 Origin::Human,
1223 )
1224 .with_command(command)
1225 .with_requesting_process("kovra ui (web admin)")
1227 .with_allow_password(true)
1232}
1233
1234fn parse_coord(s: &str) -> Result<Coordinate, AppError> {
1235 let with_scheme = if s.starts_with("secret:") {
1236 s.to_string()
1237 } else {
1238 format!("secret:{s}")
1239 };
1240 let coord = Coordinate::from_str(&with_scheme).map_err(|e| AppError::bad(e.to_string()))?;
1241 coord
1243 .canonical_path()
1244 .map_err(|e| AppError::bad(format!("{e} (coordinate must be concrete)")))?;
1245 Ok(coord)
1246}
1247
1248fn segments(coord: &Coordinate) -> (String, String, String) {
1249 use kovra_core::EnvSegment;
1250 let env = match &coord.environment {
1251 EnvSegment::Literal(e) => e.clone(),
1252 EnvSegment::Placeholder => unreachable!("parse_coord rejects placeholders"),
1253 };
1254 (env, coord.component.clone(), coord.key.clone())
1255}
1256
1257fn vault_dir(registry: &Registry, project: Option<&str>) -> PathBuf {
1258 match project {
1259 Some(p) => registry.project_dir(p),
1260 None => registry.global_dir(),
1261 }
1262}
1263
1264fn write(
1265 dir: &std::path::Path,
1266 coord: &Coordinate,
1267 record: &SecretRecord,
1268 key: &[u8; kovra_core::KEY_LEN],
1269) -> Result<(), AppError> {
1270 let sealed = kovra_core::seal(record, key).map_err(|e| AppError::internal(e.to_string()))?;
1271 store::write_record(dir, coord, &sealed).map_err(|e| AppError::internal(e.to_string()))
1272}
1273
1274fn sensitivity_str(s: Sensitivity) -> &'static str {
1275 match s {
1276 Sensitivity::Low => "low",
1277 Sensitivity::Medium => "medium",
1278 Sensitivity::High => "high",
1279 Sensitivity::InjectOnly => "inject-only",
1280 }
1281}
1282
1283fn parse_sensitivity(s: Option<&str>) -> Result<Option<Sensitivity>, AppError> {
1284 match s {
1285 None => Ok(None),
1286 Some(v) => match v.to_ascii_lowercase().replace('_', "-").as_str() {
1287 "low" => Ok(Some(Sensitivity::Low)),
1288 "medium" => Ok(Some(Sensitivity::Medium)),
1289 "high" => Ok(Some(Sensitivity::High)),
1290 "inject-only" => Ok(Some(Sensitivity::InjectOnly)),
1291 other => Err(AppError::bad(format!("unknown sensitivity `{other}`"))),
1292 },
1293 }
1294}
1295
1296fn apply_edit(
1297 existing: SecretRecord,
1298 new_sensitivity: Option<Sensitivity>,
1299 new_description: Option<String>,
1300 new_reference: Option<String>,
1301 new_revealable: Option<bool>,
1302 now: String,
1303) -> Result<SecretRecord, AppError> {
1304 match existing {
1305 SecretRecord::Literal {
1306 value,
1307 sensitivity,
1308 revealable,
1309 environment,
1310 component,
1311 key,
1312 description,
1313 created,
1314 ..
1315 } => {
1316 if new_reference.is_some() {
1317 return Err(AppError::bad(
1318 "`reference` edits a reference secret; this is a literal",
1319 ));
1320 }
1321 Ok(SecretRecord::Literal {
1322 value,
1323 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1324 revealable: new_revealable.unwrap_or(revealable),
1325 environment,
1326 component,
1327 key,
1328 description: new_description.or(description),
1329 created,
1330 updated: now,
1331 })
1332 }
1333 SecretRecord::Reference {
1334 reference,
1335 sensitivity,
1336 revealable,
1337 environment,
1338 component,
1339 key,
1340 description,
1341 created,
1342 ..
1343 } => Ok(SecretRecord::Reference {
1344 reference: new_reference.unwrap_or(reference),
1345 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1346 revealable: new_revealable.unwrap_or(revealable),
1347 environment,
1348 component,
1349 key,
1350 description: new_description.or(description),
1351 created,
1352 updated: now,
1353 }),
1354 SecretRecord::Keypair {
1355 algorithm,
1356 private,
1357 public,
1358 sensitivity,
1359 revealable,
1360 environment,
1361 component,
1362 key,
1363 description,
1364 created,
1365 ..
1366 } => {
1367 if new_reference.is_some() {
1368 return Err(AppError::bad(
1369 "`reference` edits a reference secret; this is a keypair",
1370 ));
1371 }
1372 Ok(SecretRecord::Keypair {
1373 algorithm,
1374 private,
1375 public,
1376 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1377 revealable: new_revealable.unwrap_or(revealable),
1378 environment,
1379 component,
1380 key,
1381 description: new_description.or(description),
1382 created,
1383 updated: now,
1384 })
1385 }
1386 SecretRecord::Totp {
1387 seed,
1388 algorithm,
1389 digits,
1390 period,
1391 sensitivity,
1392 revealable,
1393 environment,
1394 component,
1395 key,
1396 description,
1397 created,
1398 ..
1399 } => {
1400 if new_reference.is_some() {
1401 return Err(AppError::bad(
1402 "`reference` edits a reference secret; this is a TOTP enrollment",
1403 ));
1404 }
1405 Ok(SecretRecord::Totp {
1406 seed,
1407 algorithm,
1408 digits,
1409 period,
1410 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1411 revealable: new_revealable.unwrap_or(revealable),
1412 environment,
1413 component,
1414 key,
1415 description: new_description.or(description),
1416 created,
1417 updated: now,
1418 })
1419 }
1420 }
1421}
1422
1423pub async fn serve(
1429 listener: tokio::net::TcpListener,
1430 state: AppState,
1431 idle: Duration,
1432 persistent: bool,
1433) -> std::io::Result<()> {
1434 let app = build_app(state.clone());
1435 if persistent {
1436 if !idle.is_zero() {
1443 tokio::spawn(idle_lock_watchdog(state.clone(), idle));
1444 }
1445 #[cfg(target_os = "macos")]
1451 let _screenlock = {
1452 let st = state.clone();
1453 kovra_native_macos::watch_screen_lock(Box::new(move || st.lock()))
1454 };
1455 axum::serve(listener, app)
1456 .with_graceful_shutdown(async {
1457 let _ = tokio::signal::ctrl_c().await;
1458 })
1459 .await
1460 } else {
1461 axum::serve(listener, app)
1462 .with_graceful_shutdown(shutdown_signal(state, idle))
1463 .await
1464 }
1465}
1466
1467async fn idle_lock_watchdog(state: AppState, idle: Duration) {
1471 let tick = Duration::from_secs(5).min(idle).max(Duration::from_secs(1));
1472 loop {
1473 tokio::time::sleep(tick).await;
1474 if !state.is_locked() && state.idle_for() >= idle {
1475 state.lock();
1476 }
1477 }
1478}
1479
1480async fn shutdown_signal(state: AppState, idle: Duration) {
1482 let ctrl_c = async {
1483 let _ = tokio::signal::ctrl_c().await;
1484 };
1485 let idle_watchdog = async {
1486 let tick = Duration::from_secs(5).min(idle);
1487 loop {
1488 tokio::time::sleep(tick).await;
1489 if state.idle_for() >= idle {
1490 break;
1491 }
1492 }
1493 };
1494 tokio::select! {
1495 _ = ctrl_c => {}
1496 _ = idle_watchdog => {}
1497 }
1498}
1499
1500pub fn default_addr(port: u16) -> SocketAddr {
1502 SocketAddr::from(([127, 0, 0, 1], port))
1503}
1504
1505pub fn parse_master_key(raw: &[u8]) -> Result<MasterKey, String> {
1512 if raw.len() == kovra_core::KEY_LEN {
1514 let mut key = [0u8; kovra_core::KEY_LEN];
1515 key.copy_from_slice(raw);
1516 return Ok(MasterKey::new(key));
1517 }
1518 let text = std::str::from_utf8(raw)
1520 .map_err(|_| "master key file is neither raw bytes nor UTF-8 hex".to_string())?
1521 .trim();
1522 if text.len() != kovra_core::KEY_LEN * 2 {
1523 return Err(format!(
1524 "master key must be {} raw bytes or {} hex chars (got {} chars)",
1525 kovra_core::KEY_LEN,
1526 kovra_core::KEY_LEN * 2,
1527 text.len()
1528 ));
1529 }
1530 let mut key = [0u8; kovra_core::KEY_LEN];
1531 for (i, pair) in text.as_bytes().chunks(2).enumerate() {
1532 let hi = (pair[0] as char)
1533 .to_digit(16)
1534 .ok_or_else(|| "master key hex is invalid".to_string())?;
1535 let lo = (pair[1] as char)
1536 .to_digit(16)
1537 .ok_or_else(|| "master key hex is invalid".to_string())?;
1538 key[i] = (hi * 16 + lo) as u8;
1539 }
1540 Ok(MasterKey::new(key))
1541}
1542
1543const INDEX_HTML: &str = r##"<!doctype html>
1549<html lang="en" data-theme="dark"><head>
1550<meta charset="utf-8"><title>kovra — local admin</title>
1551<meta name="viewport" content="width=device-width, initial-scale=1">
1552<link rel="icon" type="image/svg+xml" href="/assets/kovra-iconmark.svg">
1553<link rel="stylesheet" href="/assets/tabulator/tabulator.min.css">
1554<link rel="stylesheet" href="/assets/app.css">
1555</head><body>
1556<div class="app">
1557 <aside class="side">
1558 <div class="brand">
1559 <div class="logo"><img src="/assets/kovra-mark-color.png" alt="kovra"></div>
1560 <div><div class="name">ko<span class="v">v</span>ra</div><div class="tag">local secrets · v__KOVRA_VERSION__</div></div>
1561 </div>
1562 <nav class="nav">
1563 <a id="nav-home" class="on" href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8M5 9.5V21h14V9.5"/></svg>Home</a>
1564 <div class="navgroup">
1565 <button id="proj-toggle" class="navgroup-h" aria-expanded="false">
1566 <svg class="gi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>
1567 <span class="gl">Projects</span>
1568 <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
1569 </button>
1570 <div id="proj-list" class="navgroup-items" hidden></div>
1571 </div>
1572 </nav>
1573 <div class="spacer"></div>
1574 <div class="vault"><span class="dot"></span><div><div class="who">local vault</div><div class="sub">loopback only</div></div></div>
1575 </aside>
1576 <div class="main">
1577 <div class="top">
1578 <div class="search">
1579 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/></svg>
1580 <input id="search" type="search" placeholder="Search secrets, coordinates, projects…" autocomplete="off" spellcheck="false">
1581 </div>
1582 <span class="looppill"><span class="d"></span>loopback</span>
1583 <button class="iconbtn" id="refresh" title="Refresh">
1584 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
1585 </button>
1586 <button class="iconbtn" id="theme" title="Toggle theme">
1587 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/></svg>
1588 </button>
1589 </div>
1590 <div class="content">
1591 <!-- HOME: overview — metrics, pending intakes, recent secrets -->
1592 <section id="page-home">
1593 <div class="head">
1594 <div><h1>Home</h1><div class="sub"><span id="home-sub">overview</span> · governed by sensitivity · loopback only</div></div>
1595 <div class="right">
1596 <button class="btn primary" id="home-new"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>New secret</button>
1597 </div>
1598 </div>
1599 <div class="stats">
1600 <div class="stat"><div class="n" id="stat-total">—</div><div class="l"><span class="d" style="background:var(--accent)"></span>total secrets</div></div>
1601 <div class="stat"><div class="n" id="stat-high">—</div><div class="l"><span class="d" style="background:var(--high)"></span>high / critical</div></div>
1602 <div class="stat"><div class="n" id="stat-inject">—</div><div class="l"><span class="d" style="background:var(--inj)"></span>inject-only</div></div>
1603 <div class="stat"><div class="n" id="stat-ref">—</div><div class="l"><span class="d" style="background:var(--med)"></span>references</div></div>
1604 </div>
1605 <div class="home-grid">
1606 <div class="card pad">
1607 <div class="card-h"><h2>Pending intakes</h2><span class="pill" id="intake-count">0</span></div>
1608 <div id="intake-list" class="intake-list"></div>
1609 </div>
1610 <div class="card pad">
1611 <div class="card-h"><h2>Recent secrets</h2></div>
1612 <div id="recent" class="recent"></div>
1613 </div>
1614 </div>
1615 </section>
1616 <!-- SECRETS: the full inventory (table / tree), scoped by the sidebar -->
1617 <section id="page-secrets" hidden>
1618 <div class="head">
1619 <div><h1 id="secrets-title">Secrets</h1><div class="sub"><span id="status">loading…</span> · governed by sensitivity · loopback only</div></div>
1620 <div class="right">
1621 <div class="seg">
1622 <button id="view-table" class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M3 12h18M3 19h18"/></svg>Table</button>
1623 <button id="view-tree"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 4v16M5 8h6M11 8v8M11 12h6"/></svg>Tree</button>
1624 </div>
1625 <button class="btn primary" id="new"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>New secret</button>
1626 </div>
1627 </div>
1628 <div class="card"><div id="grid"></div></div>
1629 </section>
1630 </div>
1631 </div>
1632</div>
1633
1634<div class="scrim" id="scrim"></div>
1635<aside class="drawer" id="drawer">
1636 <div class="dh"><h3 id="reveal-title">…</h3><button class="iconbtn" id="reveal-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
1637 <div class="db" id="reveal-body"></div>
1638</aside>
1639
1640<dialog id="form">
1641 <form id="form-el">
1642 <div class="mh"><h3 id="form-title">…</h3><button type="button" id="form-cancel" class="iconbtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
1643 <div class="mb" id="form-body"></div>
1644 <div class="mf">
1645 <button type="button" id="form-cancel-2" class="btn">Cancel</button>
1646 <button type="submit" id="form-submit" class="btn primary">Save</button>
1647 </div>
1648 </form>
1649</dialog>
1650<div id="toasts" aria-live="polite"></div>
1651<script src="/assets/tabulator/tabulator.min.js"></script>
1652<script src="/assets/app.js"></script>
1653</body></html>"##;
1654
1655#[cfg(test)]
1656mod tests {
1657 use super::*;
1658 use axum::body::Body;
1659 use axum::http::Request;
1660 use kovra_core::MockConfirmer;
1661 use tower::ServiceExt; const KEY: [u8; kovra_core::KEY_LEN] = [0x33; kovra_core::KEY_LEN];
1664
1665 fn state_with_confirmer(outcome: ConfirmOutcome) -> (AppState, tempfile::TempDir) {
1669 let dir = tempfile::tempdir().unwrap();
1670 Registry::open(dir.path()).unwrap();
1672 let state = AppState::new(
1673 dir.path().to_path_buf(),
1674 MasterKey::new(KEY),
1675 Arc::new(MockConfirmer::always(outcome)),
1676 );
1677 (state, dir)
1678 }
1679
1680 fn temp_state() -> (AppState, tempfile::TempDir) {
1683 state_with_confirmer(ConfirmOutcome::Approved)
1684 }
1685
1686 fn put_record(state: &AppState, record: &SecretRecord) {
1687 let registry = state.registry().unwrap();
1688 let coord = Coordinate::from_str(&format!("secret:{}", record.canonical_path())).unwrap();
1689 write(®istry.global_dir(), &coord, record, state.key()).unwrap();
1690 }
1691
1692 fn read_back(state: &AppState, coord: &str) -> Option<SecretRecord> {
1693 let c = Coordinate::from_str(&format!("secret:{coord}")).unwrap();
1694 store::read_record(&state.registry().unwrap().global_dir(), &c, state.key()).unwrap()
1695 }
1696
1697 fn api_patch(body: &str, session: &str) -> Request<Body> {
1698 Request::builder()
1699 .method("PATCH")
1700 .uri("/api/secret")
1701 .header(header::HOST, "127.0.0.1:8731")
1702 .header(header::ORIGIN, "http://127.0.0.1:8731")
1703 .header(SESSION_HEADER, session)
1704 .header(header::CONTENT_TYPE, "application/json")
1705 .body(Body::from(body.to_string()))
1706 .unwrap()
1707 }
1708
1709 fn api_delete(coord: &str, session: &str) -> Request<Body> {
1710 Request::builder()
1711 .method("DELETE")
1712 .uri(format!("/api/secret?coord={coord}"))
1713 .header(header::HOST, "127.0.0.1:8731")
1714 .header(header::ORIGIN, "http://127.0.0.1:8731")
1715 .header(SESSION_HEADER, session)
1716 .body(Body::empty())
1717 .unwrap()
1718 }
1719
1720 fn literal(env: &str, key: &str, value: &str, sens: Sensitivity) -> SecretRecord {
1721 SecretRecord::Literal {
1722 value: SecretValue::from(value),
1723 sensitivity: sens,
1724 revealable: false,
1725 environment: env.to_string(),
1726 component: "app".to_string(),
1727 key: key.to_string(),
1728 description: None,
1729 created: "2026-06-01T00:00:00Z".to_string(),
1730 updated: "2026-06-01T00:00:00Z".to_string(),
1731 }
1732 }
1733
1734 async fn body_json(resp: Response) -> Value {
1735 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1736 .await
1737 .unwrap();
1738 serde_json::from_slice(&bytes).unwrap_or(Value::Null)
1739 }
1740
1741 fn api_get(uri: &str, session: &str) -> Request<Body> {
1742 Request::builder()
1743 .method("GET")
1744 .uri(uri)
1745 .header(header::HOST, "127.0.0.1:8731")
1746 .header(SESSION_HEADER, session)
1747 .body(Body::empty())
1748 .unwrap()
1749 }
1750
1751 fn api_post(uri: &str, session: &str) -> Request<Body> {
1752 Request::builder()
1753 .method("POST")
1754 .uri(uri)
1755 .header(header::HOST, "127.0.0.1:8731")
1756 .header(header::ORIGIN, "http://127.0.0.1:8731")
1757 .header(SESSION_HEADER, session)
1758 .body(Body::empty())
1759 .unwrap()
1760 }
1761
1762 #[tokio::test]
1764 async fn medium_literal_reveals_value() {
1765 let (state, _d) = temp_state();
1766 put_record(
1767 &state,
1768 &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1769 );
1770 let app = build_app(state.clone());
1771 let resp = app
1772 .oneshot(api_get(
1773 "/api/reveal?coord=dev/app/url",
1774 state.session_token(),
1775 ))
1776 .await
1777 .unwrap();
1778 assert_eq!(resp.status(), StatusCode::OK);
1779 let j = body_json(resp).await;
1780 assert_eq!(j["value"], "postgres://x");
1781 }
1782
1783 #[tokio::test]
1786 async fn lock_latch_blocks_secret_routes_until_unlock() {
1787 let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
1788 put_record(
1789 &state,
1790 &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1791 );
1792
1793 let resp = build_app(state.clone())
1795 .oneshot(api_get(
1796 "/api/reveal?coord=dev/app/url",
1797 state.session_token(),
1798 ))
1799 .await
1800 .unwrap();
1801 assert_eq!(resp.status(), StatusCode::OK);
1802
1803 let resp = build_app(state.clone())
1805 .oneshot(api_post("/api/lock", state.session_token()))
1806 .await
1807 .unwrap();
1808 assert_eq!(resp.status(), StatusCode::OK);
1809 assert!(state.is_locked());
1810
1811 let resp = build_app(state.clone())
1813 .oneshot(api_get(
1814 "/api/reveal?coord=dev/app/url",
1815 state.session_token(),
1816 ))
1817 .await
1818 .unwrap();
1819 assert_eq!(resp.status(), StatusCode::LOCKED);
1820 let j = body_json(resp).await;
1821 assert!(j.get("value").is_none(), "a locked UI reveals no value");
1822
1823 let resp = build_app(state.clone())
1825 .oneshot(api_post("/api/unlock", state.session_token()))
1826 .await
1827 .unwrap();
1828 assert_eq!(resp.status(), StatusCode::OK);
1829 assert!(!state.is_locked());
1830 let resp = build_app(state.clone())
1831 .oneshot(api_get(
1832 "/api/reveal?coord=dev/app/url",
1833 state.session_token(),
1834 ))
1835 .await
1836 .unwrap();
1837 assert_eq!(resp.status(), StatusCode::OK);
1838 }
1839
1840 #[tokio::test]
1842 async fn unlock_denied_stays_locked() {
1843 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1844 state.lock();
1845 let resp = build_app(state.clone())
1846 .oneshot(api_post("/api/unlock", state.session_token()))
1847 .await
1848 .unwrap();
1849 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1850 assert!(state.is_locked(), "a denied unlock must leave it locked");
1851 }
1852
1853 #[tokio::test]
1855 async fn high_literal_is_masked_never_value() {
1856 let (state, _d) = temp_state();
1857 put_record(
1858 &state,
1859 &literal("dev", "key", "TOP-SECRET-HIGH", Sensitivity::High),
1860 );
1861 let app = build_app(state.clone());
1862 let resp = app
1863 .oneshot(api_get(
1864 "/api/reveal?coord=dev/app/key",
1865 state.session_token(),
1866 ))
1867 .await
1868 .unwrap();
1869 let j = body_json(resp).await;
1870 assert_eq!(j["masked"], json!(true));
1871 assert!(j.get("value").is_none(), "high must not return a value");
1872 assert!(j["fingerprint"].is_string());
1873 assert!(
1875 !serde_json::to_string(&j)
1876 .unwrap()
1877 .contains("TOP-SECRET-HIGH")
1878 );
1879 }
1880
1881 #[tokio::test]
1883 async fn inject_only_returns_metadata_only() {
1884 let (state, _d) = temp_state();
1885 put_record(
1886 &state,
1887 &literal("dev", "tok", "INJECT-ONLY-VAL", Sensitivity::InjectOnly),
1888 );
1889 let app = build_app(state.clone());
1890 let resp = app
1891 .oneshot(api_get(
1892 "/api/reveal?coord=dev/app/tok",
1893 state.session_token(),
1894 ))
1895 .await
1896 .unwrap();
1897 let j = body_json(resp).await;
1898 assert_eq!(j["inject_only"], json!(true));
1899 assert!(j.get("value").is_none());
1900 assert!(
1901 !serde_json::to_string(&j)
1902 .unwrap()
1903 .contains("INJECT-ONLY-VAL")
1904 );
1905 }
1906
1907 #[tokio::test]
1909 async fn reference_reveals_pointer_only() {
1910 let (state, _d) = temp_state();
1911 put_record(
1912 &state,
1913 &SecretRecord::Reference {
1914 reference: "azure-kv://corp-kv/api".to_string(),
1915 sensitivity: Sensitivity::High,
1916 revealable: false,
1917 environment: "dev".to_string(),
1918 component: "app".to_string(),
1919 key: "api".to_string(),
1920 description: None,
1921 created: "2026-06-01T00:00:00Z".to_string(),
1922 updated: "2026-06-01T00:00:00Z".to_string(),
1923 },
1924 );
1925 let app = build_app(state.clone());
1926 let resp = app
1927 .oneshot(api_get(
1928 "/api/reveal?coord=dev/app/api",
1929 state.session_token(),
1930 ))
1931 .await
1932 .unwrap();
1933 let j = body_json(resp).await;
1934 assert_eq!(j["kind"], "reference");
1935 assert_eq!(j["pointer"], "azure-kv://corp-kv/api");
1936 assert!(j.get("value").is_none());
1937 }
1938
1939 #[tokio::test]
1941 async fn listing_is_metadata_only() {
1942 let (state, _d) = temp_state();
1943 put_record(
1944 &state,
1945 &literal("dev", "url", "secret-listing-value", Sensitivity::Medium),
1946 );
1947 let app = build_app(state.clone());
1948 let resp = app
1949 .oneshot(api_get("/api/secrets", state.session_token()))
1950 .await
1951 .unwrap();
1952 let j = body_json(resp).await;
1953 let txt = serde_json::to_string(&j).unwrap();
1954 assert!(txt.contains("dev/app/url"));
1955 assert!(
1956 !txt.contains("secret-listing-value"),
1957 "listing must not carry values"
1958 );
1959 }
1960
1961 #[tokio::test]
1963 async fn api_requires_session_token() {
1964 let (state, _d) = temp_state();
1965 let app = build_app(state.clone());
1966 let resp = app
1967 .oneshot(api_get("/api/secrets", "wrong-token"))
1968 .await
1969 .unwrap();
1970 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1971 }
1972
1973 #[tokio::test]
1975 async fn non_loopback_host_is_rejected() {
1976 let (state, _d) = temp_state();
1977 let app = build_app(state.clone());
1978 let req = Request::builder()
1979 .method("GET")
1980 .uri("/api/secrets")
1981 .header(header::HOST, "evil.example.com")
1982 .header(SESSION_HEADER, state.session_token())
1983 .body(Body::empty())
1984 .unwrap();
1985 let resp = app.oneshot(req).await.unwrap();
1986 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1987 }
1988
1989 #[tokio::test]
1993 async fn localhost_name_host_is_rejected() {
1994 let (state, _d) = temp_state();
1995 let req = Request::builder()
1996 .method("GET")
1997 .uri("/api/secrets")
1998 .header(header::HOST, "localhost:8731")
1999 .header(SESSION_HEADER, state.session_token())
2000 .body(Body::empty())
2001 .unwrap();
2002 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2003 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2004 }
2005
2006 #[tokio::test]
2008 async fn cross_origin_is_rejected() {
2009 let (state, _d) = temp_state();
2010 let app = build_app(state.clone());
2011 let req = Request::builder()
2012 .method("GET")
2013 .uri("/api/secrets")
2014 .header(header::HOST, "127.0.0.1:8731")
2015 .header(header::ORIGIN, "http://evil.example.com")
2016 .header(SESSION_HEADER, state.session_token())
2017 .body(Body::empty())
2018 .unwrap();
2019 let resp = app.oneshot(req).await.unwrap();
2020 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2021 }
2022
2023 #[tokio::test]
2028 async fn state_change_without_origin_is_rejected() {
2029 let (state, _d) = temp_state();
2030 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2031 let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2032 let req = Request::builder()
2033 .method("PATCH")
2034 .uri("/api/secret")
2035 .header(header::HOST, "127.0.0.1:8731")
2036 .header(SESSION_HEADER, state.session_token())
2038 .header(header::CONTENT_TYPE, "application/json")
2039 .body(Body::from(body))
2040 .unwrap();
2041 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2042 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2043 assert_eq!(
2045 read_back(&state, "dev/app/url").unwrap().sensitivity(),
2046 Sensitivity::Medium
2047 );
2048 }
2049
2050 #[tokio::test]
2053 async fn get_without_origin_still_allowed() {
2054 let (state, _d) = temp_state();
2055 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2056 let resp = build_app(state.clone())
2057 .oneshot(api_get("/api/secrets", state.session_token()))
2058 .await
2059 .unwrap();
2060 assert_eq!(resp.status(), StatusCode::OK);
2061 }
2062
2063 #[tokio::test]
2066 async fn responses_carry_security_headers() {
2067 let (state, _d) = temp_state();
2068 let resp = build_app(state.clone())
2069 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2070 .await
2071 .unwrap();
2072 let h = resp.headers();
2073 let csp = h
2074 .get(header::CONTENT_SECURITY_POLICY)
2075 .and_then(|v| v.to_str().ok())
2076 .unwrap_or("");
2077 assert!(
2078 csp.contains("frame-ancestors 'none'"),
2079 "CSP frame-ancestors"
2080 );
2081 assert!(csp.contains("script-src 'self'"), "CSP script-src self");
2082 assert_eq!(
2083 h.get(header::X_FRAME_OPTIONS).and_then(|v| v.to_str().ok()),
2084 Some("DENY")
2085 );
2086 assert_eq!(
2087 h.get(header::REFERRER_POLICY).and_then(|v| v.to_str().ok()),
2088 Some("no-referrer")
2089 );
2090 assert_eq!(
2091 h.get(header::CACHE_CONTROL).and_then(|v| v.to_str().ok()),
2092 Some("no-store")
2093 );
2094 }
2095
2096 #[tokio::test]
2098 async fn crud_round_trip() {
2099 let (state, _d) = temp_state();
2100 let app = build_app(state.clone());
2101 let body = json!({"coord":"dev/app/new","value":"v1","sensitivity":"medium"}).to_string();
2103 let req = Request::builder()
2104 .method("POST")
2105 .uri("/api/secret")
2106 .header(header::HOST, "127.0.0.1:8731")
2107 .header(header::ORIGIN, "http://127.0.0.1:8731")
2108 .header(SESSION_HEADER, state.session_token())
2109 .header(header::CONTENT_TYPE, "application/json")
2110 .body(Body::from(body))
2111 .unwrap();
2112 let resp = app.clone().oneshot(req).await.unwrap();
2113 assert_eq!(resp.status(), StatusCode::OK, "create failed");
2114 let resp = build_app(state.clone())
2116 .oneshot(api_get(
2117 "/api/reveal?coord=dev/app/new",
2118 state.session_token(),
2119 ))
2120 .await
2121 .unwrap();
2122 assert_eq!(body_json(resp).await["value"], "v1");
2123 let req = Request::builder()
2125 .method("DELETE")
2126 .uri("/api/secret?coord=dev/app/new")
2127 .header(header::HOST, "127.0.0.1:8731")
2128 .header(header::ORIGIN, "http://127.0.0.1:8731")
2129 .header(SESSION_HEADER, state.session_token())
2130 .body(Body::empty())
2131 .unwrap();
2132 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2133 assert_eq!(resp.status(), StatusCode::OK);
2134 }
2135
2136 #[tokio::test]
2139 async fn downgrade_of_high_denied_leaves_record_unchanged() {
2140 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2141 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2142 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2143 let resp = build_app(state.clone())
2144 .oneshot(api_patch(&body, state.session_token()))
2145 .await
2146 .unwrap();
2147 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2148 assert_eq!(
2149 read_back(&state, "dev/app/key").unwrap().sensitivity(),
2150 Sensitivity::High,
2151 "denied downgrade must not lower sensitivity"
2152 );
2153 }
2154
2155 #[tokio::test]
2157 async fn downgrade_of_high_approved_lowers_sensitivity() {
2158 let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
2159 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2160 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2161 let resp = build_app(state.clone())
2162 .oneshot(api_patch(&body, state.session_token()))
2163 .await
2164 .unwrap();
2165 assert_eq!(resp.status(), StatusCode::OK);
2166 assert_eq!(
2167 read_back(&state, "dev/app/key").unwrap().sensitivity(),
2168 Sensitivity::Low
2169 );
2170 }
2171
2172 #[tokio::test]
2175 async fn noncritical_downgrade_is_not_gated() {
2176 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2177 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2178 let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2179 let resp = build_app(state.clone())
2180 .oneshot(api_patch(&body, state.session_token()))
2181 .await
2182 .unwrap();
2183 assert_eq!(resp.status(), StatusCode::OK);
2184 assert_eq!(
2185 read_back(&state, "dev/app/url").unwrap().sensitivity(),
2186 Sensitivity::Low
2187 );
2188 }
2189
2190 #[tokio::test]
2193 async fn delete_of_high_denied_keeps_record() {
2194 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2195 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2196 let resp = build_app(state.clone())
2197 .oneshot(api_delete("dev/app/key", state.session_token()))
2198 .await
2199 .unwrap();
2200 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2201 assert!(
2202 read_back(&state, "dev/app/key").is_some(),
2203 "denied delete of a critical secret must keep the record"
2204 );
2205 }
2206
2207 #[tokio::test]
2211 async fn delete_of_low_is_not_broker_gated() {
2212 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2213 put_record(&state, &literal("dev", "url", "v", Sensitivity::Low));
2214 let resp = build_app(state.clone())
2215 .oneshot(api_delete("dev/app/url", state.session_token()))
2216 .await
2217 .unwrap();
2218 assert_eq!(resp.status(), StatusCode::OK);
2219 assert!(
2220 read_back(&state, "dev/app/url").is_none(),
2221 "non-critical delete must not consult the broker"
2222 );
2223 }
2224
2225 #[test]
2228 fn master_key_parses_raw_and_hex() {
2229 let raw = [0x33u8; kovra_core::KEY_LEN];
2230 let from_raw = parse_master_key(&raw).unwrap();
2231 assert_eq!(from_raw.expose(), &raw);
2232
2233 let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
2234 let from_hex = parse_master_key(hex.as_bytes()).unwrap();
2235 assert_eq!(from_hex.expose(), &raw);
2236
2237 let from_hex_nl = parse_master_key(format!("{hex}\n").as_bytes()).unwrap();
2239 assert_eq!(from_hex_nl.expose(), &raw);
2240
2241 assert!(parse_master_key(b"too-short").is_err());
2243 assert!(parse_master_key(&[0u8; kovra_core::KEY_LEN - 1]).is_err());
2244 let bad_hex = "z".repeat(kovra_core::KEY_LEN * 2);
2245 assert!(parse_master_key(bad_hex.as_bytes()).is_err());
2246 }
2247
2248 #[tokio::test]
2250 async fn generate_never_returns_value_and_prod_is_high() {
2251 let (state, _d) = temp_state();
2252 let body = json!({"coord":"prod/app/gen","length":24}).to_string();
2253 let req = Request::builder()
2254 .method("POST")
2255 .uri("/api/generate")
2256 .header(header::HOST, "127.0.0.1:8731")
2257 .header(header::ORIGIN, "http://127.0.0.1:8731")
2258 .header(SESSION_HEADER, state.session_token())
2259 .header(header::CONTENT_TYPE, "application/json")
2260 .body(Body::from(body))
2261 .unwrap();
2262 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2263 let j = body_json(resp).await;
2264 assert_eq!(j["sensitivity"], "high", "prod born high (I5)");
2265 assert!(j.get("value").is_none(), "generate never returns the value");
2266 let resp = build_app(state.clone())
2268 .oneshot(api_get(
2269 "/api/reveal?coord=prod/app/gen",
2270 state.session_token(),
2271 ))
2272 .await
2273 .unwrap();
2274 assert_eq!(body_json(resp).await["masked"], json!(true));
2275 }
2276
2277 async fn body_text(resp: Response) -> (StatusCode, String, String) {
2280 let status = resp.status();
2281 let ctype = resp
2282 .headers()
2283 .get(header::CONTENT_TYPE)
2284 .and_then(|v| v.to_str().ok())
2285 .unwrap_or_default()
2286 .to_string();
2287 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2288 .await
2289 .unwrap();
2290 (status, ctype, String::from_utf8_lossy(&bytes).into_owned())
2291 }
2292
2293 fn get_loopback(uri: &str, host: &str) -> Request<Body> {
2294 Request::builder()
2295 .method("GET")
2296 .uri(uri)
2297 .header(header::HOST, host)
2298 .body(Body::empty())
2299 .unwrap()
2300 }
2301
2302 #[tokio::test]
2305 async fn index_shell_references_assets_and_has_no_inline_logic() {
2306 let (state, _d) = temp_state();
2307 let resp = build_app(state.clone())
2308 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2309 .await
2310 .unwrap();
2311 let (status, _ct, html) = body_text(resp).await;
2312 assert_eq!(status, StatusCode::OK);
2313 assert!(html.contains(r#"src="/assets/tabulator/tabulator.min.js""#));
2314 assert!(html.contains(r#"src="/assets/app.js""#));
2315 assert!(html.contains(r#"<div id="grid">"#));
2316 assert!(
2318 !html.contains("fetch('/api/secrets'") && !html.contains("/api/reveal?"),
2319 "shell must not embed inline API logic"
2320 );
2321 }
2322
2323 #[tokio::test]
2325 async fn embedded_assets_are_served_with_types() {
2326 let (state, _d) = temp_state();
2327 let cases = [
2328 (
2329 "/assets/tabulator/tabulator.min.js",
2330 "javascript",
2331 "Tabulator",
2332 ),
2333 (
2334 "/assets/tabulator/tabulator.min.css",
2335 "text/css",
2336 ".tabulator",
2337 ),
2338 ("/assets/app.js", "javascript", "kovra Web UI v2"),
2339 ("/assets/app.css", "text/css", "kovra Web UI v2"),
2340 ];
2341 for (uri, want_ct, want_body) in cases {
2342 let resp = build_app(state.clone())
2343 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2344 .await
2345 .unwrap();
2346 let (status, ct, body) = body_text(resp).await;
2347 assert_eq!(status, StatusCode::OK, "{uri}");
2348 assert!(ct.contains(want_ct), "{uri} content-type was `{ct}`");
2349 assert!(body.contains(want_body), "{uri} body missing `{want_body}`");
2350 }
2351 }
2352
2353 #[tokio::test]
2356 async fn embedded_brand_binary_assets_are_served() {
2357 let (state, _d) = temp_state();
2358 let cases = [
2359 ("/assets/kovra-appicon.svg", "image/svg+xml; charset=utf-8"),
2360 ("/assets/kovra-iconmark.svg", "image/svg+xml; charset=utf-8"),
2361 ("/assets/fonts/sora-latin-600-normal.woff2", "font/woff2"),
2362 ("/assets/fonts/inter-latin-400-normal.woff2", "font/woff2"),
2363 ("/assets/fonts/inter-latin-500-normal.woff2", "font/woff2"),
2364 ("/assets/fonts/inter-latin-600-normal.woff2", "font/woff2"),
2365 ];
2366 for (uri, want_ct) in cases {
2367 let resp = build_app(state.clone())
2368 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2369 .await
2370 .unwrap();
2371 let status = resp.status();
2372 let ct = resp
2373 .headers()
2374 .get(header::CONTENT_TYPE)
2375 .and_then(|v| v.to_str().ok())
2376 .unwrap_or_default()
2377 .to_string();
2378 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2379 .await
2380 .unwrap();
2381 assert_eq!(status, StatusCode::OK, "{uri}");
2382 assert_eq!(ct, want_ct, "{uri} content-type");
2383 assert!(!bytes.is_empty(), "{uri} body is empty");
2384 }
2385 }
2386
2387 #[tokio::test]
2390 async fn index_shell_has_brand_chrome() {
2391 let (state, _d) = temp_state();
2392 let resp = build_app(state.clone())
2393 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2394 .await
2395 .unwrap();
2396 let (status, _ct, html) = body_text(resp).await;
2397 assert_eq!(status, StatusCode::OK);
2398 assert!(html.contains(r#"href="/assets/kovra-iconmark.svg""#));
2399 assert!(html.contains(r#"src="/assets/kovra-mark-color.png""#));
2400 assert!(html.contains(r#"id="theme""#));
2401 assert!(html.contains(r#"id="drawer""#));
2402 assert!(html.contains(r#"id="nav-home""#));
2404 assert!(html.contains(r#"id="proj-toggle""#));
2405 assert!(html.contains(r#"id="page-home""#));
2407 assert!(html.contains(r#"id="stat-total""#));
2408 assert!(html.contains(r#"id="intake-list""#));
2409 assert!(html.contains(r#"id="view-table""#));
2411 assert!(html.contains(r#"id="view-tree""#));
2412 }
2413
2414 #[tokio::test]
2417 async fn assets_need_no_session_but_are_loopback_guarded() {
2418 let (state, _d) = temp_state();
2419 let resp = build_app(state.clone())
2421 .oneshot(get_loopback("/assets/app.js", "127.0.0.1:8731"))
2422 .await
2423 .unwrap();
2424 assert_eq!(resp.status(), StatusCode::OK);
2425 let resp = build_app(state.clone())
2427 .oneshot(get_loopback("/assets/app.js", "evil.example.com"))
2428 .await
2429 .unwrap();
2430 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2431 }
2432
2433 #[tokio::test]
2436 async fn api_secrets_contract_is_metadata_only() {
2437 let (state, _d) = temp_state();
2438 put_record(
2439 &state,
2440 &literal(
2441 "dev",
2442 "url",
2443 "should-not-appear-in-listing",
2444 Sensitivity::Medium,
2445 ),
2446 );
2447 let resp = build_app(state.clone())
2448 .oneshot(api_get("/api/secrets", state.session_token()))
2449 .await
2450 .unwrap();
2451 let j = body_json(resp).await;
2452 let row = &j["secrets"][0];
2453 for k in ["coordinate", "sensitivity", "mode", "fingerprint"] {
2454 assert!(row.get(k).is_some(), "row missing `{k}`");
2455 }
2456 assert!(
2457 row.get("value").is_none(),
2458 "listing must never carry a value"
2459 );
2460 let txt = serde_json::to_string(&j).unwrap();
2461 assert!(!txt.contains("should-not-appear-in-listing"));
2462 }
2463}