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<&'static str> {
462 Html(INDEX_HTML)
463}
464
465async fn list_secrets(
469 State(state): State<AppState>,
470 Query(q): Query<ScopeQuery>,
471) -> Result<Json<Value>, AppError> {
472 let registry = state.registry()?;
473 let mut rows: Vec<Value> = Vec::new();
474 let mut global_coords: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
475
476 let mut collect = |dir: PathBuf, origin: String| -> Result<(), AppError> {
477 let outcome =
478 store::load_all(&dir, state.key()).map_err(|e| AppError::internal(e.to_string()))?;
479 for (_, record) in outcome.records {
480 if origin == "global" {
481 global_coords.insert(record.canonical_path());
482 }
483 rows.push(row_for(&record, &origin));
484 }
485 Ok(())
486 };
487
488 match q.project.as_deref() {
489 Some(p) => collect(registry.project_dir(p), format!("project:{p}"))?,
490 None => {
491 collect(registry.global_dir(), "global".to_string())?;
492 for name in registry
493 .list_projects()
494 .map_err(|e| AppError::internal(e.to_string()))?
495 {
496 collect(registry.project_dir(&name), format!("project:{name}"))?;
497 }
498 }
499 }
500
501 for row in &mut rows {
503 let is_project = row
504 .get("origin")
505 .and_then(|o| o.as_str())
506 .is_some_and(|o| o.starts_with("project:"));
507 let coord = row.get("coordinate").and_then(|c| c.as_str()).unwrap_or("");
508 if is_project && global_coords.contains(coord) {
509 row["shadows_global"] = json!(true);
510 }
511 }
512
513 Ok(Json(json!({ "secrets": rows })))
514}
515
516fn row_for(record: &SecretRecord, origin: &str) -> Value {
520 let base = json!({
521 "origin": origin,
522 "coordinate": record.canonical_path(),
523 "environment": record.environment(),
524 "component": record.component(),
525 "key": record.key(),
526 "sensitivity": sensitivity_str(record.sensitivity()),
527 "revealable": record.revealable(),
528 "shadows_global": false,
529 "created": record.created(),
530 "updated": record.updated(),
531 });
532 let mut v = base;
533 match record {
534 SecretRecord::Literal { value, .. } => {
535 v["mode"] = json!("literal");
536 v["fingerprint"] = json!(fingerprint(value.expose()));
537 }
538 SecretRecord::Reference { reference, .. } => {
539 v["mode"] = json!("reference");
540 v["pointer"] = json!(reference);
541 }
542 SecretRecord::Keypair {
543 algorithm,
544 private,
545 public,
546 ..
547 } => {
548 v["mode"] = json!(if private.is_some() {
549 "keypair"
550 } else {
551 "public-only"
552 });
553 v["algorithm"] = json!(algorithm.as_str());
554 v["public"] = json!(public); v["fingerprint"] = json!(fingerprint(public.as_bytes()));
556 }
557 SecretRecord::Totp {
558 algorithm,
559 digits,
560 period,
561 ..
562 } => {
563 v["mode"] = json!("totp");
564 v["algorithm"] = json!(algorithm.as_str());
565 v["digits"] = json!(digits);
566 v["period"] = json!(period);
567 }
568 }
569 v
570}
571
572async fn reveal_secret(
577 State(state): State<AppState>,
578 Query(q): Query<CoordQuery>,
579) -> Result<Json<Value>, AppError> {
580 let coord = parse_coord(&q.coord)?;
581 let registry = state.registry()?;
582 let record = match registry
583 .resolve_with_key(&coord, q.project.as_deref(), state.key())
584 .map_err(|e| AppError::internal(e.to_string()))?
585 {
586 Resolution::Found { record, origin } => {
587 let _ = origin; record
589 }
590 Resolution::NotFound => {
591 return Err(AppError::not_found(format!("no secret at `{}`", q.coord)));
592 }
593 };
594 let canonical = record.canonical_path();
595 let env = record.environment().to_string();
596 let sensitivity = record.sensitivity();
597
598 match &record {
600 SecretRecord::Reference { reference, .. } => {
601 return Ok(Json(json!({
602 "coordinate": canonical,
603 "kind": "reference",
604 "pointer": reference,
605 "status": "unverified",
606 "note": "value not stored; materialized at run time by the provider (I8)"
607 })));
608 }
609 SecretRecord::Keypair {
610 algorithm,
611 private,
612 public,
613 ..
614 } => {
615 return Ok(Json(json!({
616 "coordinate": canonical,
617 "kind": if private.is_some() { "keypair" } else { "public-only" },
618 "algorithm": algorithm.as_str(),
619 "public": public,
620 "note": "private half is custodied; use the CLI (sign/decrypt/ssh-add)"
621 })));
622 }
623 SecretRecord::Totp {
624 algorithm,
625 digits,
626 period,
627 ..
628 } => {
629 return Ok(Json(json!({
630 "coordinate": canonical,
631 "kind": "totp",
632 "algorithm": algorithm.as_str(),
633 "digits": digits,
634 "period": period,
635 "note": "seed is custodied; derive a code with the CLI (`kovra code`)"
636 })));
637 }
638 SecretRecord::Literal { .. } => {}
639 }
640
641 let SecretRecord::Literal {
642 value, revealable, ..
643 } = &record
644 else {
645 unreachable!("non-literal handled above");
646 };
647
648 let request = AccessRequest {
649 coordinate: &coord,
650 project: q.project.as_deref(),
651 sensitivity,
652 revealable: *revealable,
653 operation: Operation::Reveal,
654 surface: Surface::WebUi,
655 origin: Origin::Human,
656 };
657 match decide(&request, &AgentScope::full()) {
658 Decision::Allow => {
659 let value_str = String::from_utf8_lossy(value.expose()).into_owned();
662 state.audit(AuditAction::Reveal, "revealed", &canonical, &env);
663 Ok(Json(json!({
664 "coordinate": canonical,
665 "kind": "literal",
666 "sensitivity": sensitivity_str(sensitivity),
667 "value": value_str
668 })))
669 }
670 Decision::Deny(reason) => {
671 use kovra_core::DenyReason;
674 let body = match reason {
675 DenyReason::WebUiCriticalMasked => json!({
676 "coordinate": canonical,
677 "kind": "literal",
678 "sensitivity": sensitivity_str(sensitivity),
679 "masked": true,
680 "fingerprint": fingerprint(value.expose()),
681 "note": "high — masked in the browser (I1); reveal via the CLI's biometric channel"
682 }),
683 DenyReason::InjectOnlyNeverRevealed => json!({
684 "coordinate": canonical,
685 "kind": "literal",
686 "sensitivity": sensitivity_str(sensitivity),
687 "inject_only": true,
688 "note": "inject-only — never revealed on any surface (I2)"
689 }),
690 other => json!({
691 "coordinate": canonical,
692 "kind": "literal",
693 "masked": true,
694 "note": format!("not revealable here: {other:?}")
695 }),
696 };
697 state.audit(AuditAction::Reveal, "masked", &canonical, &env);
698 Ok(Json(body))
699 }
700 Decision::Unaddressable => Err(AppError::not_found("not addressable")),
701 Decision::RequireConfirmation => {
702 Ok(Json(json!({
705 "coordinate": canonical,
706 "kind": "literal",
707 "masked": true,
708 "fingerprint": fingerprint(value.expose()),
709 "note": "requires confirmation — reveal via the CLI"
710 })))
711 }
712 }
713}
714
715#[derive(Deserialize)]
716struct CreateBody {
717 coord: String,
718 project: Option<String>,
719 value: Option<String>,
720 reference: Option<String>,
721 sensitivity: Option<String>,
722 description: Option<String>,
723 #[serde(default)]
724 revealable: bool,
725}
726
727async fn create_secret(
730 State(state): State<AppState>,
731 Json(body): Json<CreateBody>,
732) -> Result<Json<Value>, AppError> {
733 let coord = parse_coord(&body.coord)?;
734 let (env, component, key) = segments(&coord);
735 let registry = state.registry()?;
736 let dir = vault_dir(®istry, body.project.as_deref());
737
738 if store::read_record(&dir, &coord, state.key())
739 .map_err(|e| AppError::internal(e.to_string()))?
740 .is_some()
741 {
742 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
743 }
744 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
745 let born = birth_sensitivity(&env, chosen);
746 let now = SystemClock.now_rfc3339();
747 let record = match (&body.reference, &body.value) {
748 (Some(reference), _) => SecretRecord::Reference {
749 reference: reference.clone(),
750 sensitivity: born,
751 revealable: body.revealable,
752 environment: env.clone(),
753 component,
754 key,
755 description: body.description.clone(),
756 created: now.clone(),
757 updated: now,
758 },
759 (None, Some(value)) => SecretRecord::Literal {
760 value: SecretValue::from(value.as_str()),
761 sensitivity: born,
762 revealable: body.revealable,
763 environment: env.clone(),
764 component,
765 key,
766 description: body.description.clone(),
767 created: now.clone(),
768 updated: now,
769 },
770 (None, None) => return Err(AppError::bad("provide `value` or `reference`")),
771 };
772 write(&dir, &coord, &record, state.key())?;
773 state.audit(
774 AuditAction::Create,
775 "created",
776 &record.canonical_path(),
777 &env,
778 );
779 Ok(Json(
780 json!({ "created": record.canonical_path(), "sensitivity": sensitivity_str(born) }),
781 ))
782}
783
784#[derive(Deserialize)]
785struct UpdateBody {
786 coord: String,
787 project: Option<String>,
788 value: String,
789}
790
791async fn update_value(
794 State(state): State<AppState>,
795 Json(body): Json<UpdateBody>,
796) -> Result<Json<Value>, AppError> {
797 let coord = parse_coord(&body.coord)?;
798 let registry = state.registry()?;
799 let dir = vault_dir(®istry, body.project.as_deref());
800 let existing = store::read_record(&dir, &coord, state.key())
801 .map_err(|e| AppError::internal(e.to_string()))?
802 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
803 let now = SystemClock.now_rfc3339();
804 let record = match existing {
805 SecretRecord::Literal {
806 sensitivity,
807 revealable,
808 environment,
809 component,
810 key,
811 description,
812 created,
813 ..
814 } => SecretRecord::Literal {
815 value: SecretValue::from(body.value.as_str()),
816 sensitivity,
817 revealable,
818 environment,
819 component,
820 key,
821 description,
822 created,
823 updated: now,
824 },
825 _ => return Err(AppError::bad("only a literal's value can be updated here")),
826 };
827 write(&dir, &coord, &record, state.key())?;
828 state.audit(
829 AuditAction::Edit,
830 "value-updated",
831 &record.canonical_path(),
832 record.environment(),
833 );
834 Ok(Json(json!({ "updated": record.canonical_path() })))
835}
836
837#[derive(Deserialize)]
838struct EditBody {
839 coord: String,
840 project: Option<String>,
841 sensitivity: Option<String>,
842 description: Option<String>,
843 reference: Option<String>,
844 revealable: Option<bool>,
845}
846
847async fn edit_metadata(
850 State(state): State<AppState>,
851 Json(body): Json<EditBody>,
852) -> Result<Json<Value>, AppError> {
853 let coord = parse_coord(&body.coord)?;
854 let registry = state.registry()?;
855 let dir = vault_dir(®istry, body.project.as_deref());
856 let existing = store::read_record(&dir, &coord, state.key())
857 .map_err(|e| AppError::internal(e.to_string()))?
858 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
859 let new_sensitivity = parse_sensitivity(body.sensitivity.as_deref())?;
860 let env = existing.environment().to_string();
861 let lowered = matches!(new_sensitivity, Some(s) if is_downgrade(existing.sensitivity(), s));
862
863 if let Some(new) = new_sensitivity
868 && downgrade_requires_confirmation(existing.sensitivity(), new)
869 {
870 let canonical = existing.canonical_path();
871 let req = ui_action_request(
872 &existing,
873 format!(
874 "edit {canonical} --sensitivity {} (downgrade, web ui)",
875 sensitivity_str(new)
876 ),
877 );
878 match confirm_action(state.confirmer(), req).await {
879 ConfirmOutcome::Approved => {
880 state.audit(AuditAction::Approve, "approved-downgrade", &canonical, &env);
881 }
882 ConfirmOutcome::Denied => {
883 state.audit(AuditAction::Deny, "denied-downgrade", &canonical, &env);
884 return Err(AppError::new(
885 StatusCode::FORBIDDEN,
886 "denied — sensitivity not lowered",
887 ));
888 }
889 ConfirmOutcome::TimedOut => {
890 state.audit(AuditAction::Timeout, "timeout-downgrade", &canonical, &env);
891 return Err(AppError::new(
892 StatusCode::REQUEST_TIMEOUT,
893 "timed out — sensitivity not lowered",
894 ));
895 }
896 }
897 }
898
899 let now = SystemClock.now_rfc3339();
900 let updated = apply_edit(
901 existing,
902 new_sensitivity,
903 body.description.clone(),
904 body.reference.clone(),
905 body.revealable,
906 now,
907 )?;
908 write(&dir, &coord, &updated, state.key())?;
909 if lowered {
910 state.audit(
911 AuditAction::SensitivityDowngrade,
912 "downgraded",
913 &updated.canonical_path(),
914 &env,
915 );
916 }
917 state.audit(
918 AuditAction::Edit,
919 "metadata-updated",
920 &updated.canonical_path(),
921 &env,
922 );
923 Ok(Json(json!({ "edited": updated.canonical_path() })))
924}
925
926async fn delete_secret(
928 State(state): State<AppState>,
929 Query(q): Query<CoordQuery>,
930) -> Result<Json<Value>, AppError> {
931 let coord = parse_coord(&q.coord)?;
932 let registry = state.registry()?;
933 let dir = vault_dir(®istry, q.project.as_deref());
934 let existing = store::read_record(&dir, &coord, state.key())
935 .map_err(|e| AppError::internal(e.to_string()))?
936 .ok_or_else(|| AppError::not_found(format!("`{}` not found", q.coord)))?;
937 let canonical = existing.canonical_path();
938 let env = existing.environment().to_string();
939
940 if delete_requires_confirmation(existing.sensitivity()) {
948 let req = ui_action_request(&existing, format!("delete {canonical} (web ui)"));
949 match confirm_action(state.confirmer(), req).await {
950 ConfirmOutcome::Approved => {
951 state.audit(AuditAction::Approve, "approved-delete", &canonical, &env);
952 }
953 ConfirmOutcome::Denied => {
954 state.audit(AuditAction::Deny, "denied-delete", &canonical, &env);
955 return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not deleted"));
956 }
957 ConfirmOutcome::TimedOut => {
958 state.audit(AuditAction::Timeout, "timeout-delete", &canonical, &env);
959 return Err(AppError::new(
960 StatusCode::REQUEST_TIMEOUT,
961 "timed out — not deleted",
962 ));
963 }
964 }
965 }
966
967 store::delete_record(&dir, &coord).map_err(|e| AppError::internal(e.to_string()))?;
968 state.audit(AuditAction::Delete, "deleted", &canonical, &env);
969 Ok(Json(json!({ "deleted": canonical })))
970}
971
972#[derive(Deserialize)]
973struct GenerateBody {
974 coord: String,
975 project: Option<String>,
976 length: Option<usize>,
977 sensitivity: Option<String>,
978 description: Option<String>,
979}
980
981async fn generate_secret(
984 State(state): State<AppState>,
985 Json(body): Json<GenerateBody>,
986) -> Result<Json<Value>, AppError> {
987 let coord = parse_coord(&body.coord)?;
988 let (env, component, key) = segments(&coord);
989 let registry = state.registry()?;
990 let dir = vault_dir(®istry, body.project.as_deref());
991 if store::read_record(&dir, &coord, state.key())
992 .map_err(|e| AppError::internal(e.to_string()))?
993 .is_some()
994 {
995 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
996 }
997 let length = body.length.unwrap_or(32);
998 if length == 0 {
999 return Err(AppError::bad("length must be at least 1"));
1000 }
1001 use rand::Rng;
1002 use rand::distributions::Alphanumeric;
1003 let generated: String = rand::rngs::OsRng
1004 .sample_iter(&Alphanumeric)
1005 .take(length)
1006 .map(char::from)
1007 .collect();
1008 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
1009 let born = birth_sensitivity(&env, chosen);
1010 let now = SystemClock.now_rfc3339();
1011 let record = SecretRecord::Literal {
1012 value: SecretValue::from(generated),
1013 sensitivity: born,
1014 revealable: false,
1015 environment: env.clone(),
1016 component,
1017 key,
1018 description: body.description.clone(),
1019 created: now.clone(),
1020 updated: now,
1021 };
1022 write(&dir, &coord, &record, state.key())?;
1023 state.audit(
1024 AuditAction::Create,
1025 "generated",
1026 &record.canonical_path(),
1027 &env,
1028 );
1029 Ok(Json(json!({
1030 "generated": record.canonical_path(),
1031 "length": length,
1032 "sensitivity": sensitivity_str(born),
1033 "note": "value stored, never returned"
1034 })))
1035}
1036
1037async fn list_intakes(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
1041 let broker = IntakeBroker::under_root(state.root());
1042 let pending = broker
1043 .list_pending()
1044 .map_err(|e| AppError::internal(e.to_string()))?;
1045 let rows: Vec<Value> = pending
1046 .iter()
1047 .map(|i| {
1048 json!({
1049 "id": i.id,
1050 "coordinate": i.coordinate,
1051 "sensitivity": sensitivity_str(i.sensitivity),
1052 "environment": i.environment,
1053 "origin": format!("{:?}", i.origin).to_lowercase(),
1054 "requesting_process": i.requesting_process,
1055 "description": i.description.as_ref().map(|d| d.0.clone()),
1058 "created_unix": i.created_unix,
1059 })
1060 })
1061 .collect();
1062 Ok(Json(json!({ "intakes": rows })))
1063}
1064
1065#[derive(Deserialize)]
1066struct FulfillBody {
1067 id: String,
1068 value: String,
1069 project: Option<String>,
1070}
1071
1072async fn fulfill_intake(
1079 State(state): State<AppState>,
1080 Json(body): Json<FulfillBody>,
1081) -> Result<Json<Value>, AppError> {
1082 let broker = IntakeBroker::under_root(state.root());
1083 let intake = broker
1084 .get(&body.id)
1085 .map_err(|e| AppError::internal(e.to_string()))?
1086 .ok_or_else(|| AppError::not_found(format!("no pending intake `{}`", body.id)))?;
1087 if body.value.is_empty() {
1088 return Err(AppError::bad("value must not be empty"));
1089 }
1090 let coord = parse_coord(&intake.coordinate)?;
1091 let (env, component, key) = segments(&coord);
1092 let registry = state.registry()?;
1093 let dir = vault_dir(®istry, body.project.as_deref());
1094 if store::read_record(&dir, &coord, state.key())
1095 .map_err(|e| AppError::internal(e.to_string()))?
1096 .is_some()
1097 {
1098 return Err(AppError::bad(format!(
1099 "`{}` already exists",
1100 intake.coordinate
1101 )));
1102 }
1103 let born = birth_sensitivity(&env, intake.sensitivity); if born == Sensitivity::High {
1110 let mut req =
1111 ConfirmRequest::new(intake.coordinate.clone(), born, env.clone(), Origin::Human)
1112 .with_command(format!("fulfil {} (web ui)", intake.coordinate))
1113 .with_requesting_process("kovra ui (web admin)");
1114 if let Some(d) = &intake.description {
1115 req = req.with_requester_description(d.0.clone());
1116 }
1117 match confirm_action(state.confirmer(), req).await {
1118 ConfirmOutcome::Approved => {
1119 state.audit(
1120 AuditAction::Approve,
1121 "approved-intake",
1122 &intake.coordinate,
1123 &env,
1124 );
1125 }
1126 ConfirmOutcome::Denied => {
1127 state.audit(AuditAction::Deny, "denied-intake", &intake.coordinate, &env);
1128 return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not created"));
1129 }
1130 ConfirmOutcome::TimedOut => {
1131 state.audit(
1132 AuditAction::Timeout,
1133 "timeout-intake",
1134 &intake.coordinate,
1135 &env,
1136 );
1137 return Err(AppError::new(
1138 StatusCode::REQUEST_TIMEOUT,
1139 "timed out — not created",
1140 ));
1141 }
1142 }
1143 }
1144
1145 let now = SystemClock.now_rfc3339();
1146 let record = SecretRecord::Literal {
1147 value: SecretValue::from(body.value.as_str()),
1148 sensitivity: born,
1149 revealable: false,
1150 environment: env.clone(),
1151 component,
1152 key,
1153 description: None,
1154 created: now.clone(),
1155 updated: now,
1156 };
1157 write(&dir, &coord, &record, state.key())?;
1158 broker
1159 .cancel(&body.id)
1160 .map_err(|e| AppError::internal(e.to_string()))?;
1161 state.audit(
1162 AuditAction::Create,
1163 "fulfilled-intake",
1164 &record.canonical_path(),
1165 &env,
1166 );
1167 Ok(Json(json!({
1168 "fulfilled": record.canonical_path(),
1169 "sensitivity": sensitivity_str(born),
1170 })))
1171}
1172
1173#[derive(Deserialize)]
1174struct IdQuery {
1175 id: String,
1176}
1177
1178async fn dismiss_intake(
1182 State(state): State<AppState>,
1183 Query(q): Query<IdQuery>,
1184) -> Result<Json<Value>, AppError> {
1185 let broker = IntakeBroker::under_root(state.root());
1186 broker
1187 .cancel(&q.id)
1188 .map_err(|e| AppError::internal(e.to_string()))?;
1189 Ok(Json(json!({ "dismissed": q.id })))
1190}
1191
1192const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120);
1197
1198async fn confirm_action(
1203 confirmer: Arc<dyn Confirmer + Send + Sync>,
1204 req: ConfirmRequest,
1205) -> ConfirmOutcome {
1206 tokio::task::spawn_blocking(move || confirmer.confirm(&req, CONFIRM_TIMEOUT))
1207 .await
1208 .unwrap_or(ConfirmOutcome::Denied)
1209}
1210
1211fn ui_action_request(record: &SecretRecord, command: String) -> ConfirmRequest {
1216 ConfirmRequest::new(
1217 record.canonical_path(),
1218 record.sensitivity(),
1219 record.environment().to_string(),
1220 Origin::Human,
1221 )
1222 .with_command(command)
1223 .with_requesting_process("kovra ui (web admin)")
1225 .with_allow_password(true)
1230}
1231
1232fn parse_coord(s: &str) -> Result<Coordinate, AppError> {
1233 let with_scheme = if s.starts_with("secret:") {
1234 s.to_string()
1235 } else {
1236 format!("secret:{s}")
1237 };
1238 let coord = Coordinate::from_str(&with_scheme).map_err(|e| AppError::bad(e.to_string()))?;
1239 coord
1241 .canonical_path()
1242 .map_err(|e| AppError::bad(format!("{e} (coordinate must be concrete)")))?;
1243 Ok(coord)
1244}
1245
1246fn segments(coord: &Coordinate) -> (String, String, String) {
1247 use kovra_core::EnvSegment;
1248 let env = match &coord.environment {
1249 EnvSegment::Literal(e) => e.clone(),
1250 EnvSegment::Placeholder => unreachable!("parse_coord rejects placeholders"),
1251 };
1252 (env, coord.component.clone(), coord.key.clone())
1253}
1254
1255fn vault_dir(registry: &Registry, project: Option<&str>) -> PathBuf {
1256 match project {
1257 Some(p) => registry.project_dir(p),
1258 None => registry.global_dir(),
1259 }
1260}
1261
1262fn write(
1263 dir: &std::path::Path,
1264 coord: &Coordinate,
1265 record: &SecretRecord,
1266 key: &[u8; kovra_core::KEY_LEN],
1267) -> Result<(), AppError> {
1268 let sealed = kovra_core::seal(record, key).map_err(|e| AppError::internal(e.to_string()))?;
1269 store::write_record(dir, coord, &sealed).map_err(|e| AppError::internal(e.to_string()))
1270}
1271
1272fn sensitivity_str(s: Sensitivity) -> &'static str {
1273 match s {
1274 Sensitivity::Low => "low",
1275 Sensitivity::Medium => "medium",
1276 Sensitivity::High => "high",
1277 Sensitivity::InjectOnly => "inject-only",
1278 }
1279}
1280
1281fn parse_sensitivity(s: Option<&str>) -> Result<Option<Sensitivity>, AppError> {
1282 match s {
1283 None => Ok(None),
1284 Some(v) => match v.to_ascii_lowercase().replace('_', "-").as_str() {
1285 "low" => Ok(Some(Sensitivity::Low)),
1286 "medium" => Ok(Some(Sensitivity::Medium)),
1287 "high" => Ok(Some(Sensitivity::High)),
1288 "inject-only" => Ok(Some(Sensitivity::InjectOnly)),
1289 other => Err(AppError::bad(format!("unknown sensitivity `{other}`"))),
1290 },
1291 }
1292}
1293
1294fn apply_edit(
1295 existing: SecretRecord,
1296 new_sensitivity: Option<Sensitivity>,
1297 new_description: Option<String>,
1298 new_reference: Option<String>,
1299 new_revealable: Option<bool>,
1300 now: String,
1301) -> Result<SecretRecord, AppError> {
1302 match existing {
1303 SecretRecord::Literal {
1304 value,
1305 sensitivity,
1306 revealable,
1307 environment,
1308 component,
1309 key,
1310 description,
1311 created,
1312 ..
1313 } => {
1314 if new_reference.is_some() {
1315 return Err(AppError::bad(
1316 "`reference` edits a reference secret; this is a literal",
1317 ));
1318 }
1319 Ok(SecretRecord::Literal {
1320 value,
1321 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1322 revealable: new_revealable.unwrap_or(revealable),
1323 environment,
1324 component,
1325 key,
1326 description: new_description.or(description),
1327 created,
1328 updated: now,
1329 })
1330 }
1331 SecretRecord::Reference {
1332 reference,
1333 sensitivity,
1334 revealable,
1335 environment,
1336 component,
1337 key,
1338 description,
1339 created,
1340 ..
1341 } => Ok(SecretRecord::Reference {
1342 reference: new_reference.unwrap_or(reference),
1343 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1344 revealable: new_revealable.unwrap_or(revealable),
1345 environment,
1346 component,
1347 key,
1348 description: new_description.or(description),
1349 created,
1350 updated: now,
1351 }),
1352 SecretRecord::Keypair {
1353 algorithm,
1354 private,
1355 public,
1356 sensitivity,
1357 revealable,
1358 environment,
1359 component,
1360 key,
1361 description,
1362 created,
1363 ..
1364 } => {
1365 if new_reference.is_some() {
1366 return Err(AppError::bad(
1367 "`reference` edits a reference secret; this is a keypair",
1368 ));
1369 }
1370 Ok(SecretRecord::Keypair {
1371 algorithm,
1372 private,
1373 public,
1374 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1375 revealable: new_revealable.unwrap_or(revealable),
1376 environment,
1377 component,
1378 key,
1379 description: new_description.or(description),
1380 created,
1381 updated: now,
1382 })
1383 }
1384 SecretRecord::Totp {
1385 seed,
1386 algorithm,
1387 digits,
1388 period,
1389 sensitivity,
1390 revealable,
1391 environment,
1392 component,
1393 key,
1394 description,
1395 created,
1396 ..
1397 } => {
1398 if new_reference.is_some() {
1399 return Err(AppError::bad(
1400 "`reference` edits a reference secret; this is a TOTP enrollment",
1401 ));
1402 }
1403 Ok(SecretRecord::Totp {
1404 seed,
1405 algorithm,
1406 digits,
1407 period,
1408 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1409 revealable: new_revealable.unwrap_or(revealable),
1410 environment,
1411 component,
1412 key,
1413 description: new_description.or(description),
1414 created,
1415 updated: now,
1416 })
1417 }
1418 }
1419}
1420
1421pub async fn serve(
1427 listener: tokio::net::TcpListener,
1428 state: AppState,
1429 idle: Duration,
1430 persistent: bool,
1431) -> std::io::Result<()> {
1432 let app = build_app(state.clone());
1433 if persistent {
1434 if !idle.is_zero() {
1441 tokio::spawn(idle_lock_watchdog(state.clone(), idle));
1442 }
1443 #[cfg(target_os = "macos")]
1449 let _screenlock = {
1450 let st = state.clone();
1451 kovra_native_macos::watch_screen_lock(Box::new(move || st.lock()))
1452 };
1453 axum::serve(listener, app)
1454 .with_graceful_shutdown(async {
1455 let _ = tokio::signal::ctrl_c().await;
1456 })
1457 .await
1458 } else {
1459 axum::serve(listener, app)
1460 .with_graceful_shutdown(shutdown_signal(state, idle))
1461 .await
1462 }
1463}
1464
1465async fn idle_lock_watchdog(state: AppState, idle: Duration) {
1469 let tick = Duration::from_secs(5).min(idle).max(Duration::from_secs(1));
1470 loop {
1471 tokio::time::sleep(tick).await;
1472 if !state.is_locked() && state.idle_for() >= idle {
1473 state.lock();
1474 }
1475 }
1476}
1477
1478async fn shutdown_signal(state: AppState, idle: Duration) {
1480 let ctrl_c = async {
1481 let _ = tokio::signal::ctrl_c().await;
1482 };
1483 let idle_watchdog = async {
1484 let tick = Duration::from_secs(5).min(idle);
1485 loop {
1486 tokio::time::sleep(tick).await;
1487 if state.idle_for() >= idle {
1488 break;
1489 }
1490 }
1491 };
1492 tokio::select! {
1493 _ = ctrl_c => {}
1494 _ = idle_watchdog => {}
1495 }
1496}
1497
1498pub fn default_addr(port: u16) -> SocketAddr {
1500 SocketAddr::from(([127, 0, 0, 1], port))
1501}
1502
1503pub fn parse_master_key(raw: &[u8]) -> Result<MasterKey, String> {
1510 if raw.len() == kovra_core::KEY_LEN {
1512 let mut key = [0u8; kovra_core::KEY_LEN];
1513 key.copy_from_slice(raw);
1514 return Ok(MasterKey::new(key));
1515 }
1516 let text = std::str::from_utf8(raw)
1518 .map_err(|_| "master key file is neither raw bytes nor UTF-8 hex".to_string())?
1519 .trim();
1520 if text.len() != kovra_core::KEY_LEN * 2 {
1521 return Err(format!(
1522 "master key must be {} raw bytes or {} hex chars (got {} chars)",
1523 kovra_core::KEY_LEN,
1524 kovra_core::KEY_LEN * 2,
1525 text.len()
1526 ));
1527 }
1528 let mut key = [0u8; kovra_core::KEY_LEN];
1529 for (i, pair) in text.as_bytes().chunks(2).enumerate() {
1530 let hi = (pair[0] as char)
1531 .to_digit(16)
1532 .ok_or_else(|| "master key hex is invalid".to_string())?;
1533 let lo = (pair[1] as char)
1534 .to_digit(16)
1535 .ok_or_else(|| "master key hex is invalid".to_string())?;
1536 key[i] = (hi * 16 + lo) as u8;
1537 }
1538 Ok(MasterKey::new(key))
1539}
1540
1541const INDEX_HTML: &str = r##"<!doctype html>
1547<html lang="en" data-theme="dark"><head>
1548<meta charset="utf-8"><title>kovra — local admin</title>
1549<meta name="viewport" content="width=device-width, initial-scale=1">
1550<link rel="icon" type="image/svg+xml" href="/assets/kovra-iconmark.svg">
1551<link rel="stylesheet" href="/assets/tabulator/tabulator.min.css">
1552<link rel="stylesheet" href="/assets/app.css">
1553</head><body>
1554<div class="app">
1555 <aside class="side">
1556 <div class="brand">
1557 <div class="logo"><img src="/assets/kovra-mark-color.png" alt="kovra"></div>
1558 <div><div class="name">ko<span class="v">v</span>ra</div><div class="tag">local secrets</div></div>
1559 </div>
1560 <nav class="nav">
1561 <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>
1562 <div class="navgroup">
1563 <button id="proj-toggle" class="navgroup-h" aria-expanded="false">
1564 <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>
1565 <span class="gl">Projects</span>
1566 <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>
1567 </button>
1568 <div id="proj-list" class="navgroup-items" hidden></div>
1569 </div>
1570 </nav>
1571 <div class="spacer"></div>
1572 <div class="vault"><span class="dot"></span><div><div class="who">local vault</div><div class="sub">loopback only</div></div></div>
1573 </aside>
1574 <div class="main">
1575 <div class="top">
1576 <div class="search">
1577 <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>
1578 <input id="search" type="search" placeholder="Search secrets, coordinates, projects…" autocomplete="off" spellcheck="false">
1579 </div>
1580 <span class="looppill"><span class="d"></span>loopback</span>
1581 <button class="iconbtn" id="refresh" title="Refresh">
1582 <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>
1583 </button>
1584 <button class="iconbtn" id="theme" title="Toggle theme">
1585 <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>
1586 </button>
1587 </div>
1588 <div class="content">
1589 <!-- HOME: overview — metrics, pending intakes, recent secrets -->
1590 <section id="page-home">
1591 <div class="head">
1592 <div><h1>Home</h1><div class="sub"><span id="home-sub">overview</span> · governed by sensitivity · loopback only</div></div>
1593 <div class="right">
1594 <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>
1595 </div>
1596 </div>
1597 <div class="stats">
1598 <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>
1599 <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>
1600 <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>
1601 <div class="stat"><div class="n" id="stat-ref">—</div><div class="l"><span class="d" style="background:var(--med)"></span>references</div></div>
1602 </div>
1603 <div class="home-grid">
1604 <div class="card pad">
1605 <div class="card-h"><h2>Pending intakes</h2><span class="pill" id="intake-count">0</span></div>
1606 <div id="intake-list" class="intake-list"></div>
1607 </div>
1608 <div class="card pad">
1609 <div class="card-h"><h2>Recent secrets</h2></div>
1610 <div id="recent" class="recent"></div>
1611 </div>
1612 </div>
1613 </section>
1614 <!-- SECRETS: the full inventory (table / tree), scoped by the sidebar -->
1615 <section id="page-secrets" hidden>
1616 <div class="head">
1617 <div><h1 id="secrets-title">Secrets</h1><div class="sub"><span id="status">loading…</span> · governed by sensitivity · loopback only</div></div>
1618 <div class="right">
1619 <div class="seg">
1620 <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>
1621 <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>
1622 </div>
1623 <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>
1624 </div>
1625 </div>
1626 <div class="card"><div id="grid"></div></div>
1627 </section>
1628 </div>
1629 </div>
1630</div>
1631
1632<div class="scrim" id="scrim"></div>
1633<aside class="drawer" id="drawer">
1634 <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>
1635 <div class="db" id="reveal-body"></div>
1636</aside>
1637
1638<dialog id="form">
1639 <form id="form-el">
1640 <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>
1641 <div class="mb" id="form-body"></div>
1642 <div class="mf">
1643 <button type="button" id="form-cancel-2" class="btn">Cancel</button>
1644 <button type="submit" id="form-submit" class="btn primary">Save</button>
1645 </div>
1646 </form>
1647</dialog>
1648<div id="toasts" aria-live="polite"></div>
1649<script src="/assets/tabulator/tabulator.min.js"></script>
1650<script src="/assets/app.js"></script>
1651</body></html>"##;
1652
1653#[cfg(test)]
1654mod tests {
1655 use super::*;
1656 use axum::body::Body;
1657 use axum::http::Request;
1658 use kovra_core::MockConfirmer;
1659 use tower::ServiceExt; const KEY: [u8; kovra_core::KEY_LEN] = [0x33; kovra_core::KEY_LEN];
1662
1663 fn state_with_confirmer(outcome: ConfirmOutcome) -> (AppState, tempfile::TempDir) {
1667 let dir = tempfile::tempdir().unwrap();
1668 Registry::open(dir.path()).unwrap();
1670 let state = AppState::new(
1671 dir.path().to_path_buf(),
1672 MasterKey::new(KEY),
1673 Arc::new(MockConfirmer::always(outcome)),
1674 );
1675 (state, dir)
1676 }
1677
1678 fn temp_state() -> (AppState, tempfile::TempDir) {
1681 state_with_confirmer(ConfirmOutcome::Approved)
1682 }
1683
1684 fn put_record(state: &AppState, record: &SecretRecord) {
1685 let registry = state.registry().unwrap();
1686 let coord = Coordinate::from_str(&format!("secret:{}", record.canonical_path())).unwrap();
1687 write(®istry.global_dir(), &coord, record, state.key()).unwrap();
1688 }
1689
1690 fn read_back(state: &AppState, coord: &str) -> Option<SecretRecord> {
1691 let c = Coordinate::from_str(&format!("secret:{coord}")).unwrap();
1692 store::read_record(&state.registry().unwrap().global_dir(), &c, state.key()).unwrap()
1693 }
1694
1695 fn api_patch(body: &str, session: &str) -> Request<Body> {
1696 Request::builder()
1697 .method("PATCH")
1698 .uri("/api/secret")
1699 .header(header::HOST, "127.0.0.1:8731")
1700 .header(header::ORIGIN, "http://127.0.0.1:8731")
1701 .header(SESSION_HEADER, session)
1702 .header(header::CONTENT_TYPE, "application/json")
1703 .body(Body::from(body.to_string()))
1704 .unwrap()
1705 }
1706
1707 fn api_delete(coord: &str, session: &str) -> Request<Body> {
1708 Request::builder()
1709 .method("DELETE")
1710 .uri(format!("/api/secret?coord={coord}"))
1711 .header(header::HOST, "127.0.0.1:8731")
1712 .header(header::ORIGIN, "http://127.0.0.1:8731")
1713 .header(SESSION_HEADER, session)
1714 .body(Body::empty())
1715 .unwrap()
1716 }
1717
1718 fn literal(env: &str, key: &str, value: &str, sens: Sensitivity) -> SecretRecord {
1719 SecretRecord::Literal {
1720 value: SecretValue::from(value),
1721 sensitivity: sens,
1722 revealable: false,
1723 environment: env.to_string(),
1724 component: "app".to_string(),
1725 key: key.to_string(),
1726 description: None,
1727 created: "2026-06-01T00:00:00Z".to_string(),
1728 updated: "2026-06-01T00:00:00Z".to_string(),
1729 }
1730 }
1731
1732 async fn body_json(resp: Response) -> Value {
1733 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1734 .await
1735 .unwrap();
1736 serde_json::from_slice(&bytes).unwrap_or(Value::Null)
1737 }
1738
1739 fn api_get(uri: &str, session: &str) -> Request<Body> {
1740 Request::builder()
1741 .method("GET")
1742 .uri(uri)
1743 .header(header::HOST, "127.0.0.1:8731")
1744 .header(SESSION_HEADER, session)
1745 .body(Body::empty())
1746 .unwrap()
1747 }
1748
1749 fn api_post(uri: &str, session: &str) -> Request<Body> {
1750 Request::builder()
1751 .method("POST")
1752 .uri(uri)
1753 .header(header::HOST, "127.0.0.1:8731")
1754 .header(header::ORIGIN, "http://127.0.0.1:8731")
1755 .header(SESSION_HEADER, session)
1756 .body(Body::empty())
1757 .unwrap()
1758 }
1759
1760 #[tokio::test]
1762 async fn medium_literal_reveals_value() {
1763 let (state, _d) = temp_state();
1764 put_record(
1765 &state,
1766 &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1767 );
1768 let app = build_app(state.clone());
1769 let resp = app
1770 .oneshot(api_get(
1771 "/api/reveal?coord=dev/app/url",
1772 state.session_token(),
1773 ))
1774 .await
1775 .unwrap();
1776 assert_eq!(resp.status(), StatusCode::OK);
1777 let j = body_json(resp).await;
1778 assert_eq!(j["value"], "postgres://x");
1779 }
1780
1781 #[tokio::test]
1784 async fn lock_latch_blocks_secret_routes_until_unlock() {
1785 let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
1786 put_record(
1787 &state,
1788 &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1789 );
1790
1791 let resp = build_app(state.clone())
1793 .oneshot(api_get(
1794 "/api/reveal?coord=dev/app/url",
1795 state.session_token(),
1796 ))
1797 .await
1798 .unwrap();
1799 assert_eq!(resp.status(), StatusCode::OK);
1800
1801 let resp = build_app(state.clone())
1803 .oneshot(api_post("/api/lock", state.session_token()))
1804 .await
1805 .unwrap();
1806 assert_eq!(resp.status(), StatusCode::OK);
1807 assert!(state.is_locked());
1808
1809 let resp = build_app(state.clone())
1811 .oneshot(api_get(
1812 "/api/reveal?coord=dev/app/url",
1813 state.session_token(),
1814 ))
1815 .await
1816 .unwrap();
1817 assert_eq!(resp.status(), StatusCode::LOCKED);
1818 let j = body_json(resp).await;
1819 assert!(j.get("value").is_none(), "a locked UI reveals no value");
1820
1821 let resp = build_app(state.clone())
1823 .oneshot(api_post("/api/unlock", state.session_token()))
1824 .await
1825 .unwrap();
1826 assert_eq!(resp.status(), StatusCode::OK);
1827 assert!(!state.is_locked());
1828 let resp = build_app(state.clone())
1829 .oneshot(api_get(
1830 "/api/reveal?coord=dev/app/url",
1831 state.session_token(),
1832 ))
1833 .await
1834 .unwrap();
1835 assert_eq!(resp.status(), StatusCode::OK);
1836 }
1837
1838 #[tokio::test]
1840 async fn unlock_denied_stays_locked() {
1841 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1842 state.lock();
1843 let resp = build_app(state.clone())
1844 .oneshot(api_post("/api/unlock", state.session_token()))
1845 .await
1846 .unwrap();
1847 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1848 assert!(state.is_locked(), "a denied unlock must leave it locked");
1849 }
1850
1851 #[tokio::test]
1853 async fn high_literal_is_masked_never_value() {
1854 let (state, _d) = temp_state();
1855 put_record(
1856 &state,
1857 &literal("dev", "key", "TOP-SECRET-HIGH", Sensitivity::High),
1858 );
1859 let app = build_app(state.clone());
1860 let resp = app
1861 .oneshot(api_get(
1862 "/api/reveal?coord=dev/app/key",
1863 state.session_token(),
1864 ))
1865 .await
1866 .unwrap();
1867 let j = body_json(resp).await;
1868 assert_eq!(j["masked"], json!(true));
1869 assert!(j.get("value").is_none(), "high must not return a value");
1870 assert!(j["fingerprint"].is_string());
1871 assert!(
1873 !serde_json::to_string(&j)
1874 .unwrap()
1875 .contains("TOP-SECRET-HIGH")
1876 );
1877 }
1878
1879 #[tokio::test]
1881 async fn inject_only_returns_metadata_only() {
1882 let (state, _d) = temp_state();
1883 put_record(
1884 &state,
1885 &literal("dev", "tok", "INJECT-ONLY-VAL", Sensitivity::InjectOnly),
1886 );
1887 let app = build_app(state.clone());
1888 let resp = app
1889 .oneshot(api_get(
1890 "/api/reveal?coord=dev/app/tok",
1891 state.session_token(),
1892 ))
1893 .await
1894 .unwrap();
1895 let j = body_json(resp).await;
1896 assert_eq!(j["inject_only"], json!(true));
1897 assert!(j.get("value").is_none());
1898 assert!(
1899 !serde_json::to_string(&j)
1900 .unwrap()
1901 .contains("INJECT-ONLY-VAL")
1902 );
1903 }
1904
1905 #[tokio::test]
1907 async fn reference_reveals_pointer_only() {
1908 let (state, _d) = temp_state();
1909 put_record(
1910 &state,
1911 &SecretRecord::Reference {
1912 reference: "azure-kv://corp-kv/api".to_string(),
1913 sensitivity: Sensitivity::High,
1914 revealable: false,
1915 environment: "dev".to_string(),
1916 component: "app".to_string(),
1917 key: "api".to_string(),
1918 description: None,
1919 created: "2026-06-01T00:00:00Z".to_string(),
1920 updated: "2026-06-01T00:00:00Z".to_string(),
1921 },
1922 );
1923 let app = build_app(state.clone());
1924 let resp = app
1925 .oneshot(api_get(
1926 "/api/reveal?coord=dev/app/api",
1927 state.session_token(),
1928 ))
1929 .await
1930 .unwrap();
1931 let j = body_json(resp).await;
1932 assert_eq!(j["kind"], "reference");
1933 assert_eq!(j["pointer"], "azure-kv://corp-kv/api");
1934 assert!(j.get("value").is_none());
1935 }
1936
1937 #[tokio::test]
1939 async fn listing_is_metadata_only() {
1940 let (state, _d) = temp_state();
1941 put_record(
1942 &state,
1943 &literal("dev", "url", "secret-listing-value", Sensitivity::Medium),
1944 );
1945 let app = build_app(state.clone());
1946 let resp = app
1947 .oneshot(api_get("/api/secrets", state.session_token()))
1948 .await
1949 .unwrap();
1950 let j = body_json(resp).await;
1951 let txt = serde_json::to_string(&j).unwrap();
1952 assert!(txt.contains("dev/app/url"));
1953 assert!(
1954 !txt.contains("secret-listing-value"),
1955 "listing must not carry values"
1956 );
1957 }
1958
1959 #[tokio::test]
1961 async fn api_requires_session_token() {
1962 let (state, _d) = temp_state();
1963 let app = build_app(state.clone());
1964 let resp = app
1965 .oneshot(api_get("/api/secrets", "wrong-token"))
1966 .await
1967 .unwrap();
1968 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1969 }
1970
1971 #[tokio::test]
1973 async fn non_loopback_host_is_rejected() {
1974 let (state, _d) = temp_state();
1975 let app = build_app(state.clone());
1976 let req = Request::builder()
1977 .method("GET")
1978 .uri("/api/secrets")
1979 .header(header::HOST, "evil.example.com")
1980 .header(SESSION_HEADER, state.session_token())
1981 .body(Body::empty())
1982 .unwrap();
1983 let resp = app.oneshot(req).await.unwrap();
1984 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1985 }
1986
1987 #[tokio::test]
1991 async fn localhost_name_host_is_rejected() {
1992 let (state, _d) = temp_state();
1993 let req = Request::builder()
1994 .method("GET")
1995 .uri("/api/secrets")
1996 .header(header::HOST, "localhost:8731")
1997 .header(SESSION_HEADER, state.session_token())
1998 .body(Body::empty())
1999 .unwrap();
2000 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2001 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2002 }
2003
2004 #[tokio::test]
2006 async fn cross_origin_is_rejected() {
2007 let (state, _d) = temp_state();
2008 let app = build_app(state.clone());
2009 let req = Request::builder()
2010 .method("GET")
2011 .uri("/api/secrets")
2012 .header(header::HOST, "127.0.0.1:8731")
2013 .header(header::ORIGIN, "http://evil.example.com")
2014 .header(SESSION_HEADER, state.session_token())
2015 .body(Body::empty())
2016 .unwrap();
2017 let resp = app.oneshot(req).await.unwrap();
2018 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2019 }
2020
2021 #[tokio::test]
2026 async fn state_change_without_origin_is_rejected() {
2027 let (state, _d) = temp_state();
2028 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2029 let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2030 let req = Request::builder()
2031 .method("PATCH")
2032 .uri("/api/secret")
2033 .header(header::HOST, "127.0.0.1:8731")
2034 .header(SESSION_HEADER, state.session_token())
2036 .header(header::CONTENT_TYPE, "application/json")
2037 .body(Body::from(body))
2038 .unwrap();
2039 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2040 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2041 assert_eq!(
2043 read_back(&state, "dev/app/url").unwrap().sensitivity(),
2044 Sensitivity::Medium
2045 );
2046 }
2047
2048 #[tokio::test]
2051 async fn get_without_origin_still_allowed() {
2052 let (state, _d) = temp_state();
2053 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2054 let resp = build_app(state.clone())
2055 .oneshot(api_get("/api/secrets", state.session_token()))
2056 .await
2057 .unwrap();
2058 assert_eq!(resp.status(), StatusCode::OK);
2059 }
2060
2061 #[tokio::test]
2064 async fn responses_carry_security_headers() {
2065 let (state, _d) = temp_state();
2066 let resp = build_app(state.clone())
2067 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2068 .await
2069 .unwrap();
2070 let h = resp.headers();
2071 let csp = h
2072 .get(header::CONTENT_SECURITY_POLICY)
2073 .and_then(|v| v.to_str().ok())
2074 .unwrap_or("");
2075 assert!(
2076 csp.contains("frame-ancestors 'none'"),
2077 "CSP frame-ancestors"
2078 );
2079 assert!(csp.contains("script-src 'self'"), "CSP script-src self");
2080 assert_eq!(
2081 h.get(header::X_FRAME_OPTIONS).and_then(|v| v.to_str().ok()),
2082 Some("DENY")
2083 );
2084 assert_eq!(
2085 h.get(header::REFERRER_POLICY).and_then(|v| v.to_str().ok()),
2086 Some("no-referrer")
2087 );
2088 assert_eq!(
2089 h.get(header::CACHE_CONTROL).and_then(|v| v.to_str().ok()),
2090 Some("no-store")
2091 );
2092 }
2093
2094 #[tokio::test]
2096 async fn crud_round_trip() {
2097 let (state, _d) = temp_state();
2098 let app = build_app(state.clone());
2099 let body = json!({"coord":"dev/app/new","value":"v1","sensitivity":"medium"}).to_string();
2101 let req = Request::builder()
2102 .method("POST")
2103 .uri("/api/secret")
2104 .header(header::HOST, "127.0.0.1:8731")
2105 .header(header::ORIGIN, "http://127.0.0.1:8731")
2106 .header(SESSION_HEADER, state.session_token())
2107 .header(header::CONTENT_TYPE, "application/json")
2108 .body(Body::from(body))
2109 .unwrap();
2110 let resp = app.clone().oneshot(req).await.unwrap();
2111 assert_eq!(resp.status(), StatusCode::OK, "create failed");
2112 let resp = build_app(state.clone())
2114 .oneshot(api_get(
2115 "/api/reveal?coord=dev/app/new",
2116 state.session_token(),
2117 ))
2118 .await
2119 .unwrap();
2120 assert_eq!(body_json(resp).await["value"], "v1");
2121 let req = Request::builder()
2123 .method("DELETE")
2124 .uri("/api/secret?coord=dev/app/new")
2125 .header(header::HOST, "127.0.0.1:8731")
2126 .header(header::ORIGIN, "http://127.0.0.1:8731")
2127 .header(SESSION_HEADER, state.session_token())
2128 .body(Body::empty())
2129 .unwrap();
2130 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2131 assert_eq!(resp.status(), StatusCode::OK);
2132 }
2133
2134 #[tokio::test]
2137 async fn downgrade_of_high_denied_leaves_record_unchanged() {
2138 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2139 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2140 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2141 let resp = build_app(state.clone())
2142 .oneshot(api_patch(&body, state.session_token()))
2143 .await
2144 .unwrap();
2145 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2146 assert_eq!(
2147 read_back(&state, "dev/app/key").unwrap().sensitivity(),
2148 Sensitivity::High,
2149 "denied downgrade must not lower sensitivity"
2150 );
2151 }
2152
2153 #[tokio::test]
2155 async fn downgrade_of_high_approved_lowers_sensitivity() {
2156 let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
2157 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2158 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
2159 let resp = build_app(state.clone())
2160 .oneshot(api_patch(&body, state.session_token()))
2161 .await
2162 .unwrap();
2163 assert_eq!(resp.status(), StatusCode::OK);
2164 assert_eq!(
2165 read_back(&state, "dev/app/key").unwrap().sensitivity(),
2166 Sensitivity::Low
2167 );
2168 }
2169
2170 #[tokio::test]
2173 async fn noncritical_downgrade_is_not_gated() {
2174 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2175 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
2176 let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
2177 let resp = build_app(state.clone())
2178 .oneshot(api_patch(&body, state.session_token()))
2179 .await
2180 .unwrap();
2181 assert_eq!(resp.status(), StatusCode::OK);
2182 assert_eq!(
2183 read_back(&state, "dev/app/url").unwrap().sensitivity(),
2184 Sensitivity::Low
2185 );
2186 }
2187
2188 #[tokio::test]
2191 async fn delete_of_high_denied_keeps_record() {
2192 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2193 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
2194 let resp = build_app(state.clone())
2195 .oneshot(api_delete("dev/app/key", state.session_token()))
2196 .await
2197 .unwrap();
2198 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2199 assert!(
2200 read_back(&state, "dev/app/key").is_some(),
2201 "denied delete of a critical secret must keep the record"
2202 );
2203 }
2204
2205 #[tokio::test]
2209 async fn delete_of_low_is_not_broker_gated() {
2210 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
2211 put_record(&state, &literal("dev", "url", "v", Sensitivity::Low));
2212 let resp = build_app(state.clone())
2213 .oneshot(api_delete("dev/app/url", state.session_token()))
2214 .await
2215 .unwrap();
2216 assert_eq!(resp.status(), StatusCode::OK);
2217 assert!(
2218 read_back(&state, "dev/app/url").is_none(),
2219 "non-critical delete must not consult the broker"
2220 );
2221 }
2222
2223 #[test]
2226 fn master_key_parses_raw_and_hex() {
2227 let raw = [0x33u8; kovra_core::KEY_LEN];
2228 let from_raw = parse_master_key(&raw).unwrap();
2229 assert_eq!(from_raw.expose(), &raw);
2230
2231 let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
2232 let from_hex = parse_master_key(hex.as_bytes()).unwrap();
2233 assert_eq!(from_hex.expose(), &raw);
2234
2235 let from_hex_nl = parse_master_key(format!("{hex}\n").as_bytes()).unwrap();
2237 assert_eq!(from_hex_nl.expose(), &raw);
2238
2239 assert!(parse_master_key(b"too-short").is_err());
2241 assert!(parse_master_key(&[0u8; kovra_core::KEY_LEN - 1]).is_err());
2242 let bad_hex = "z".repeat(kovra_core::KEY_LEN * 2);
2243 assert!(parse_master_key(bad_hex.as_bytes()).is_err());
2244 }
2245
2246 #[tokio::test]
2248 async fn generate_never_returns_value_and_prod_is_high() {
2249 let (state, _d) = temp_state();
2250 let body = json!({"coord":"prod/app/gen","length":24}).to_string();
2251 let req = Request::builder()
2252 .method("POST")
2253 .uri("/api/generate")
2254 .header(header::HOST, "127.0.0.1:8731")
2255 .header(header::ORIGIN, "http://127.0.0.1:8731")
2256 .header(SESSION_HEADER, state.session_token())
2257 .header(header::CONTENT_TYPE, "application/json")
2258 .body(Body::from(body))
2259 .unwrap();
2260 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
2261 let j = body_json(resp).await;
2262 assert_eq!(j["sensitivity"], "high", "prod born high (I5)");
2263 assert!(j.get("value").is_none(), "generate never returns the value");
2264 let resp = build_app(state.clone())
2266 .oneshot(api_get(
2267 "/api/reveal?coord=prod/app/gen",
2268 state.session_token(),
2269 ))
2270 .await
2271 .unwrap();
2272 assert_eq!(body_json(resp).await["masked"], json!(true));
2273 }
2274
2275 async fn body_text(resp: Response) -> (StatusCode, String, String) {
2278 let status = resp.status();
2279 let ctype = resp
2280 .headers()
2281 .get(header::CONTENT_TYPE)
2282 .and_then(|v| v.to_str().ok())
2283 .unwrap_or_default()
2284 .to_string();
2285 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2286 .await
2287 .unwrap();
2288 (status, ctype, String::from_utf8_lossy(&bytes).into_owned())
2289 }
2290
2291 fn get_loopback(uri: &str, host: &str) -> Request<Body> {
2292 Request::builder()
2293 .method("GET")
2294 .uri(uri)
2295 .header(header::HOST, host)
2296 .body(Body::empty())
2297 .unwrap()
2298 }
2299
2300 #[tokio::test]
2303 async fn index_shell_references_assets_and_has_no_inline_logic() {
2304 let (state, _d) = temp_state();
2305 let resp = build_app(state.clone())
2306 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2307 .await
2308 .unwrap();
2309 let (status, _ct, html) = body_text(resp).await;
2310 assert_eq!(status, StatusCode::OK);
2311 assert!(html.contains(r#"src="/assets/tabulator/tabulator.min.js""#));
2312 assert!(html.contains(r#"src="/assets/app.js""#));
2313 assert!(html.contains(r#"<div id="grid">"#));
2314 assert!(
2316 !html.contains("fetch('/api/secrets'") && !html.contains("/api/reveal?"),
2317 "shell must not embed inline API logic"
2318 );
2319 }
2320
2321 #[tokio::test]
2323 async fn embedded_assets_are_served_with_types() {
2324 let (state, _d) = temp_state();
2325 let cases = [
2326 (
2327 "/assets/tabulator/tabulator.min.js",
2328 "javascript",
2329 "Tabulator",
2330 ),
2331 (
2332 "/assets/tabulator/tabulator.min.css",
2333 "text/css",
2334 ".tabulator",
2335 ),
2336 ("/assets/app.js", "javascript", "kovra Web UI v2"),
2337 ("/assets/app.css", "text/css", "kovra Web UI v2"),
2338 ];
2339 for (uri, want_ct, want_body) in cases {
2340 let resp = build_app(state.clone())
2341 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2342 .await
2343 .unwrap();
2344 let (status, ct, body) = body_text(resp).await;
2345 assert_eq!(status, StatusCode::OK, "{uri}");
2346 assert!(ct.contains(want_ct), "{uri} content-type was `{ct}`");
2347 assert!(body.contains(want_body), "{uri} body missing `{want_body}`");
2348 }
2349 }
2350
2351 #[tokio::test]
2354 async fn embedded_brand_binary_assets_are_served() {
2355 let (state, _d) = temp_state();
2356 let cases = [
2357 ("/assets/kovra-appicon.svg", "image/svg+xml; charset=utf-8"),
2358 ("/assets/kovra-iconmark.svg", "image/svg+xml; charset=utf-8"),
2359 ("/assets/fonts/sora-latin-600-normal.woff2", "font/woff2"),
2360 ("/assets/fonts/inter-latin-400-normal.woff2", "font/woff2"),
2361 ("/assets/fonts/inter-latin-500-normal.woff2", "font/woff2"),
2362 ("/assets/fonts/inter-latin-600-normal.woff2", "font/woff2"),
2363 ];
2364 for (uri, want_ct) in cases {
2365 let resp = build_app(state.clone())
2366 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
2367 .await
2368 .unwrap();
2369 let status = resp.status();
2370 let ct = resp
2371 .headers()
2372 .get(header::CONTENT_TYPE)
2373 .and_then(|v| v.to_str().ok())
2374 .unwrap_or_default()
2375 .to_string();
2376 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2377 .await
2378 .unwrap();
2379 assert_eq!(status, StatusCode::OK, "{uri}");
2380 assert_eq!(ct, want_ct, "{uri} content-type");
2381 assert!(!bytes.is_empty(), "{uri} body is empty");
2382 }
2383 }
2384
2385 #[tokio::test]
2388 async fn index_shell_has_brand_chrome() {
2389 let (state, _d) = temp_state();
2390 let resp = build_app(state.clone())
2391 .oneshot(get_loopback("/", "127.0.0.1:8731"))
2392 .await
2393 .unwrap();
2394 let (status, _ct, html) = body_text(resp).await;
2395 assert_eq!(status, StatusCode::OK);
2396 assert!(html.contains(r#"href="/assets/kovra-iconmark.svg""#));
2397 assert!(html.contains(r#"src="/assets/kovra-mark-color.png""#));
2398 assert!(html.contains(r#"id="theme""#));
2399 assert!(html.contains(r#"id="drawer""#));
2400 assert!(html.contains(r#"id="nav-home""#));
2402 assert!(html.contains(r#"id="proj-toggle""#));
2403 assert!(html.contains(r#"id="page-home""#));
2405 assert!(html.contains(r#"id="stat-total""#));
2406 assert!(html.contains(r#"id="intake-list""#));
2407 assert!(html.contains(r#"id="view-table""#));
2409 assert!(html.contains(r#"id="view-tree""#));
2410 }
2411
2412 #[tokio::test]
2415 async fn assets_need_no_session_but_are_loopback_guarded() {
2416 let (state, _d) = temp_state();
2417 let resp = build_app(state.clone())
2419 .oneshot(get_loopback("/assets/app.js", "127.0.0.1:8731"))
2420 .await
2421 .unwrap();
2422 assert_eq!(resp.status(), StatusCode::OK);
2423 let resp = build_app(state.clone())
2425 .oneshot(get_loopback("/assets/app.js", "evil.example.com"))
2426 .await
2427 .unwrap();
2428 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2429 }
2430
2431 #[tokio::test]
2434 async fn api_secrets_contract_is_metadata_only() {
2435 let (state, _d) = temp_state();
2436 put_record(
2437 &state,
2438 &literal(
2439 "dev",
2440 "url",
2441 "should-not-appear-in-listing",
2442 Sensitivity::Medium,
2443 ),
2444 );
2445 let resp = build_app(state.clone())
2446 .oneshot(api_get("/api/secrets", state.session_token()))
2447 .await
2448 .unwrap();
2449 let j = body_json(resp).await;
2450 let row = &j["secrets"][0];
2451 for k in ["coordinate", "sensitivity", "mode", "fingerprint"] {
2452 assert!(row.get(k).is_some(), "row missing `{k}`");
2453 }
2454 assert!(
2455 row.get("value").is_none(),
2456 "listing must never carry a value"
2457 );
2458 let txt = serde_json::to_string(&j).unwrap();
2459 assert!(!txt.contains("should-not-appear-in-listing"));
2460 }
2461}