1use std::net::SocketAddr;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::Mutex;
29use std::time::{Duration, Instant};
30
31use axum::{
32 Json, Router,
33 extract::{Query, Request, State},
34 http::{StatusCode, header},
35 middleware::{self, Next},
36 response::{Html, IntoResponse, Response},
37 routing::{get, post},
38};
39use kovra_core::{
40 AccessRequest, AgentScope, AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome,
41 ConfirmRequest, Confirmer, Coordinate, Decision, FileAuditSink, MasterKey, Operation, Origin,
42 Registry, Resolution, SecretRecord, SecretValue, Sensitivity, Surface, SystemClock,
43 birth_sensitivity, decide, delete_requires_confirmation, downgrade_requires_confirmation,
44 fingerprint, is_downgrade, store,
45};
46use rand::RngCore;
47use serde::Deserialize;
48use serde_json::{Value, json};
49use std::str::FromStr;
50
51mod assets;
52
53pub const SESSION_HEADER: &str = "x-kovra-session";
55
56pub const DEFAULT_PORT: u16 = 8731;
58
59#[derive(Clone)]
63pub struct AppState {
64 inner: Arc<Inner>,
65}
66
67struct Inner {
68 root: PathBuf,
69 master: MasterKey,
70 session_token: String,
71 last_activity: Mutex<Instant>,
72 confirmer: Arc<dyn Confirmer + Send + Sync>,
78}
79
80impl AppState {
81 pub fn new(
86 root: PathBuf,
87 master: MasterKey,
88 confirmer: Arc<dyn Confirmer + Send + Sync>,
89 ) -> Self {
90 let mut buf = [0u8; 16];
91 rand::rngs::OsRng.fill_bytes(&mut buf);
92 let session_token = buf.iter().map(|b| format!("{b:02x}")).collect();
93 Self::new_with_session(root, master, session_token, confirmer)
94 }
95
96 pub fn new_with_session(
101 root: PathBuf,
102 master: MasterKey,
103 session_token: String,
104 confirmer: Arc<dyn Confirmer + Send + Sync>,
105 ) -> Self {
106 Self {
107 inner: Arc::new(Inner {
108 root,
109 master,
110 session_token,
111 last_activity: Mutex::new(Instant::now()),
112 confirmer,
113 }),
114 }
115 }
116
117 pub fn session_token(&self) -> &str {
119 &self.inner.session_token
120 }
121
122 fn confirmer(&self) -> Arc<dyn Confirmer + Send + Sync> {
124 Arc::clone(&self.inner.confirmer)
125 }
126
127 fn registry(&self) -> Result<Registry, AppError> {
128 Registry::open(&self.inner.root).map_err(|e| AppError::internal(e.to_string()))
129 }
130
131 fn key(&self) -> &[u8; kovra_core::KEY_LEN] {
132 self.inner.master.expose()
133 }
134
135 fn audit(&self, action: AuditAction, result: &str, canonical: &str, env: &str) {
136 let clock = SystemClock;
137 let _ = FileAuditSink::under_root(&self.inner.root).record(
138 &AuditEvent::new(&clock, action, result)
139 .at(canonical, env)
140 .by(Origin::Human),
141 );
142 }
143
144 fn touch(&self) {
145 if let Ok(mut t) = self.inner.last_activity.lock() {
146 *t = Instant::now();
147 }
148 }
149
150 fn idle_for(&self) -> Duration {
151 self.inner
152 .last_activity
153 .lock()
154 .map(|t| t.elapsed())
155 .unwrap_or_default()
156 }
157}
158
159#[derive(Debug)]
161struct AppError {
162 status: StatusCode,
163 message: String,
164}
165
166impl AppError {
167 fn new(status: StatusCode, message: impl Into<String>) -> Self {
168 Self {
169 status,
170 message: message.into(),
171 }
172 }
173 fn internal(message: impl Into<String>) -> Self {
174 Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
175 }
176 fn bad(message: impl Into<String>) -> Self {
177 Self::new(StatusCode::BAD_REQUEST, message)
178 }
179 fn not_found(message: impl Into<String>) -> Self {
180 Self::new(StatusCode::NOT_FOUND, message)
181 }
182}
183
184impl IntoResponse for AppError {
185 fn into_response(self) -> Response {
186 (self.status, Json(json!({ "error": self.message }))).into_response()
187 }
188}
189
190pub fn build_app(state: AppState) -> Router {
194 let api = Router::new()
195 .route("/secrets", get(list_secrets))
196 .route("/reveal", get(reveal_secret))
197 .route(
198 "/secret",
199 post(create_secret)
200 .put(update_value)
201 .patch(edit_metadata)
202 .delete(delete_secret),
203 )
204 .route("/generate", post(generate_secret))
205 .route_layer(middleware::from_fn_with_state(
206 state.clone(),
207 require_session,
208 ));
209
210 Router::new()
211 .route("/", get(index))
212 .merge(assets::routes())
216 .nest("/api", api)
217 .layer(middleware::from_fn_with_state(
218 state.clone(),
219 loopback_guard,
220 ))
221 .with_state(state)
222}
223
224async fn loopback_guard(State(state): State<AppState>, req: Request, next: Next) -> Response {
230 if let Some(host) = req
231 .headers()
232 .get(header::HOST)
233 .and_then(|h| h.to_str().ok())
234 && !is_loopback_host(host)
235 {
236 return AppError::new(StatusCode::FORBIDDEN, "non-loopback Host rejected (I10)")
237 .into_response();
238 }
239 if let Some(origin) = req
241 .headers()
242 .get(header::ORIGIN)
243 .and_then(|h| h.to_str().ok())
244 && !is_loopback_origin(origin)
245 {
246 return AppError::new(StatusCode::FORBIDDEN, "cross-origin request rejected")
247 .into_response();
248 }
249 state.touch();
250 next.run(req).await
251}
252
253async fn require_session(State(state): State<AppState>, req: Request, next: Next) -> Response {
256 let presented = req
257 .headers()
258 .get(SESSION_HEADER)
259 .and_then(|h| h.to_str().ok())
260 .unwrap_or_default();
261 if presented.is_empty() || presented != state.session_token() {
264 return AppError::new(StatusCode::UNAUTHORIZED, "missing or invalid session token")
265 .into_response();
266 }
267 next.run(req).await
268}
269
270fn is_loopback_host(host: &str) -> bool {
271 let h = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
273 h == "127.0.0.1" || h == "localhost" || h == "[::1]" || h == "::1"
274}
275
276fn is_loopback_origin(origin: &str) -> bool {
277 let rest = match origin.strip_prefix("http://") {
278 Some(r) => r,
279 None => match origin.strip_prefix("https://") {
280 Some(r) => r,
281 None => return false,
282 },
283 };
284 is_loopback_host(rest)
285}
286
287#[derive(Deserialize, Default)]
290struct ScopeQuery {
291 project: Option<String>,
292}
293
294#[derive(Deserialize)]
295struct CoordQuery {
296 coord: String,
297 project: Option<String>,
298}
299
300async fn index() -> Html<&'static str> {
303 Html(INDEX_HTML)
304}
305
306async fn list_secrets(
310 State(state): State<AppState>,
311 Query(q): Query<ScopeQuery>,
312) -> Result<Json<Value>, AppError> {
313 let registry = state.registry()?;
314 let mut rows: Vec<Value> = Vec::new();
315 let mut global_coords: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
316
317 let mut collect = |dir: PathBuf, origin: String| -> Result<(), AppError> {
318 let outcome =
319 store::load_all(&dir, state.key()).map_err(|e| AppError::internal(e.to_string()))?;
320 for (_, record) in outcome.records {
321 if origin == "global" {
322 global_coords.insert(record.canonical_path());
323 }
324 rows.push(row_for(&record, &origin));
325 }
326 Ok(())
327 };
328
329 match q.project.as_deref() {
330 Some(p) => collect(registry.project_dir(p), format!("project:{p}"))?,
331 None => {
332 collect(registry.global_dir(), "global".to_string())?;
333 for name in registry
334 .list_projects()
335 .map_err(|e| AppError::internal(e.to_string()))?
336 {
337 collect(registry.project_dir(&name), format!("project:{name}"))?;
338 }
339 }
340 }
341
342 for row in &mut rows {
344 let is_project = row
345 .get("origin")
346 .and_then(|o| o.as_str())
347 .is_some_and(|o| o.starts_with("project:"));
348 let coord = row.get("coordinate").and_then(|c| c.as_str()).unwrap_or("");
349 if is_project && global_coords.contains(coord) {
350 row["shadows_global"] = json!(true);
351 }
352 }
353
354 Ok(Json(json!({ "secrets": rows })))
355}
356
357fn row_for(record: &SecretRecord, origin: &str) -> Value {
361 let base = json!({
362 "origin": origin,
363 "coordinate": record.canonical_path(),
364 "environment": record.environment(),
365 "component": record.component(),
366 "key": record.key(),
367 "sensitivity": sensitivity_str(record.sensitivity()),
368 "revealable": record.revealable(),
369 "shadows_global": false,
370 });
371 let mut v = base;
372 match record {
373 SecretRecord::Literal { value, .. } => {
374 v["mode"] = json!("literal");
375 v["fingerprint"] = json!(fingerprint(value.expose()));
376 }
377 SecretRecord::Reference { reference, .. } => {
378 v["mode"] = json!("reference");
379 v["pointer"] = json!(reference);
380 }
381 SecretRecord::Keypair {
382 algorithm,
383 private,
384 public,
385 ..
386 } => {
387 v["mode"] = json!(if private.is_some() {
388 "keypair"
389 } else {
390 "public-only"
391 });
392 v["algorithm"] = json!(algorithm.as_str());
393 v["public"] = json!(public); v["fingerprint"] = json!(fingerprint(public.as_bytes()));
395 }
396 SecretRecord::Totp {
397 algorithm,
398 digits,
399 period,
400 ..
401 } => {
402 v["mode"] = json!("totp");
403 v["algorithm"] = json!(algorithm.as_str());
404 v["digits"] = json!(digits);
405 v["period"] = json!(period);
406 }
407 }
408 v
409}
410
411async fn reveal_secret(
416 State(state): State<AppState>,
417 Query(q): Query<CoordQuery>,
418) -> Result<Json<Value>, AppError> {
419 let coord = parse_coord(&q.coord)?;
420 let registry = state.registry()?;
421 let record = match registry
422 .resolve_with_key(&coord, q.project.as_deref(), state.key())
423 .map_err(|e| AppError::internal(e.to_string()))?
424 {
425 Resolution::Found { record, origin } => {
426 let _ = origin; record
428 }
429 Resolution::NotFound => {
430 return Err(AppError::not_found(format!("no secret at `{}`", q.coord)));
431 }
432 };
433 let canonical = record.canonical_path();
434 let env = record.environment().to_string();
435 let sensitivity = record.sensitivity();
436
437 match &record {
439 SecretRecord::Reference { reference, .. } => {
440 return Ok(Json(json!({
441 "coordinate": canonical,
442 "kind": "reference",
443 "pointer": reference,
444 "status": "unverified",
445 "note": "value not stored; materialized at run time by the provider (I8)"
446 })));
447 }
448 SecretRecord::Keypair {
449 algorithm,
450 private,
451 public,
452 ..
453 } => {
454 return Ok(Json(json!({
455 "coordinate": canonical,
456 "kind": if private.is_some() { "keypair" } else { "public-only" },
457 "algorithm": algorithm.as_str(),
458 "public": public,
459 "note": "private half is custodied; use the CLI (sign/decrypt/ssh-add)"
460 })));
461 }
462 SecretRecord::Totp {
463 algorithm,
464 digits,
465 period,
466 ..
467 } => {
468 return Ok(Json(json!({
469 "coordinate": canonical,
470 "kind": "totp",
471 "algorithm": algorithm.as_str(),
472 "digits": digits,
473 "period": period,
474 "note": "seed is custodied; derive a code with the CLI (`kovra code`)"
475 })));
476 }
477 SecretRecord::Literal { .. } => {}
478 }
479
480 let SecretRecord::Literal {
481 value, revealable, ..
482 } = &record
483 else {
484 unreachable!("non-literal handled above");
485 };
486
487 let request = AccessRequest {
488 coordinate: &coord,
489 project: q.project.as_deref(),
490 sensitivity,
491 revealable: *revealable,
492 operation: Operation::Reveal,
493 surface: Surface::WebUi,
494 origin: Origin::Human,
495 };
496 match decide(&request, &AgentScope::full()) {
497 Decision::Allow => {
498 let value_str = String::from_utf8_lossy(value.expose()).into_owned();
501 state.audit(AuditAction::Reveal, "revealed", &canonical, &env);
502 Ok(Json(json!({
503 "coordinate": canonical,
504 "kind": "literal",
505 "sensitivity": sensitivity_str(sensitivity),
506 "value": value_str
507 })))
508 }
509 Decision::Deny(reason) => {
510 use kovra_core::DenyReason;
513 let body = match reason {
514 DenyReason::WebUiCriticalMasked => json!({
515 "coordinate": canonical,
516 "kind": "literal",
517 "sensitivity": sensitivity_str(sensitivity),
518 "masked": true,
519 "fingerprint": fingerprint(value.expose()),
520 "note": "high — masked in the browser (I1); reveal via the CLI's biometric channel"
521 }),
522 DenyReason::InjectOnlyNeverRevealed => json!({
523 "coordinate": canonical,
524 "kind": "literal",
525 "sensitivity": sensitivity_str(sensitivity),
526 "inject_only": true,
527 "note": "inject-only — never revealed on any surface (I2)"
528 }),
529 other => json!({
530 "coordinate": canonical,
531 "kind": "literal",
532 "masked": true,
533 "note": format!("not revealable here: {other:?}")
534 }),
535 };
536 state.audit(AuditAction::Reveal, "masked", &canonical, &env);
537 Ok(Json(body))
538 }
539 Decision::Unaddressable => Err(AppError::not_found("not addressable")),
540 Decision::RequireConfirmation => {
541 Ok(Json(json!({
544 "coordinate": canonical,
545 "kind": "literal",
546 "masked": true,
547 "fingerprint": fingerprint(value.expose()),
548 "note": "requires confirmation — reveal via the CLI"
549 })))
550 }
551 }
552}
553
554#[derive(Deserialize)]
555struct CreateBody {
556 coord: String,
557 project: Option<String>,
558 value: Option<String>,
559 reference: Option<String>,
560 sensitivity: Option<String>,
561 description: Option<String>,
562 #[serde(default)]
563 revealable: bool,
564}
565
566async fn create_secret(
569 State(state): State<AppState>,
570 Json(body): Json<CreateBody>,
571) -> Result<Json<Value>, AppError> {
572 let coord = parse_coord(&body.coord)?;
573 let (env, component, key) = segments(&coord);
574 let registry = state.registry()?;
575 let dir = vault_dir(®istry, body.project.as_deref());
576
577 if store::read_record(&dir, &coord, state.key())
578 .map_err(|e| AppError::internal(e.to_string()))?
579 .is_some()
580 {
581 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
582 }
583 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
584 let born = birth_sensitivity(&env, chosen);
585 let now = SystemClock.now_rfc3339();
586 let record = match (&body.reference, &body.value) {
587 (Some(reference), _) => SecretRecord::Reference {
588 reference: reference.clone(),
589 sensitivity: born,
590 revealable: body.revealable,
591 environment: env.clone(),
592 component,
593 key,
594 description: body.description.clone(),
595 created: now.clone(),
596 updated: now,
597 },
598 (None, Some(value)) => SecretRecord::Literal {
599 value: SecretValue::from(value.as_str()),
600 sensitivity: born,
601 revealable: body.revealable,
602 environment: env.clone(),
603 component,
604 key,
605 description: body.description.clone(),
606 created: now.clone(),
607 updated: now,
608 },
609 (None, None) => return Err(AppError::bad("provide `value` or `reference`")),
610 };
611 write(&dir, &coord, &record, state.key())?;
612 state.audit(
613 AuditAction::Create,
614 "created",
615 &record.canonical_path(),
616 &env,
617 );
618 Ok(Json(
619 json!({ "created": record.canonical_path(), "sensitivity": sensitivity_str(born) }),
620 ))
621}
622
623#[derive(Deserialize)]
624struct UpdateBody {
625 coord: String,
626 project: Option<String>,
627 value: String,
628}
629
630async fn update_value(
633 State(state): State<AppState>,
634 Json(body): Json<UpdateBody>,
635) -> Result<Json<Value>, AppError> {
636 let coord = parse_coord(&body.coord)?;
637 let registry = state.registry()?;
638 let dir = vault_dir(®istry, body.project.as_deref());
639 let existing = store::read_record(&dir, &coord, state.key())
640 .map_err(|e| AppError::internal(e.to_string()))?
641 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
642 let now = SystemClock.now_rfc3339();
643 let record = match existing {
644 SecretRecord::Literal {
645 sensitivity,
646 revealable,
647 environment,
648 component,
649 key,
650 description,
651 created,
652 ..
653 } => SecretRecord::Literal {
654 value: SecretValue::from(body.value.as_str()),
655 sensitivity,
656 revealable,
657 environment,
658 component,
659 key,
660 description,
661 created,
662 updated: now,
663 },
664 _ => return Err(AppError::bad("only a literal's value can be updated here")),
665 };
666 write(&dir, &coord, &record, state.key())?;
667 state.audit(
668 AuditAction::Edit,
669 "value-updated",
670 &record.canonical_path(),
671 record.environment(),
672 );
673 Ok(Json(json!({ "updated": record.canonical_path() })))
674}
675
676#[derive(Deserialize)]
677struct EditBody {
678 coord: String,
679 project: Option<String>,
680 sensitivity: Option<String>,
681 description: Option<String>,
682 reference: Option<String>,
683 revealable: Option<bool>,
684}
685
686async fn edit_metadata(
689 State(state): State<AppState>,
690 Json(body): Json<EditBody>,
691) -> Result<Json<Value>, AppError> {
692 let coord = parse_coord(&body.coord)?;
693 let registry = state.registry()?;
694 let dir = vault_dir(®istry, body.project.as_deref());
695 let existing = store::read_record(&dir, &coord, state.key())
696 .map_err(|e| AppError::internal(e.to_string()))?
697 .ok_or_else(|| AppError::not_found(format!("`{}` not found", body.coord)))?;
698 let new_sensitivity = parse_sensitivity(body.sensitivity.as_deref())?;
699 let env = existing.environment().to_string();
700 let lowered = matches!(new_sensitivity, Some(s) if is_downgrade(existing.sensitivity(), s));
701
702 if let Some(new) = new_sensitivity
707 && downgrade_requires_confirmation(existing.sensitivity(), new)
708 {
709 let canonical = existing.canonical_path();
710 let req = ui_action_request(
711 &existing,
712 format!(
713 "edit {canonical} --sensitivity {} (downgrade, web ui)",
714 sensitivity_str(new)
715 ),
716 );
717 match confirm_action(state.confirmer(), req).await {
718 ConfirmOutcome::Approved => {
719 state.audit(AuditAction::Approve, "approved-downgrade", &canonical, &env);
720 }
721 ConfirmOutcome::Denied => {
722 state.audit(AuditAction::Deny, "denied-downgrade", &canonical, &env);
723 return Err(AppError::new(
724 StatusCode::FORBIDDEN,
725 "denied — sensitivity not lowered",
726 ));
727 }
728 ConfirmOutcome::TimedOut => {
729 state.audit(AuditAction::Timeout, "timeout-downgrade", &canonical, &env);
730 return Err(AppError::new(
731 StatusCode::REQUEST_TIMEOUT,
732 "timed out — sensitivity not lowered",
733 ));
734 }
735 }
736 }
737
738 let now = SystemClock.now_rfc3339();
739 let updated = apply_edit(
740 existing,
741 new_sensitivity,
742 body.description.clone(),
743 body.reference.clone(),
744 body.revealable,
745 now,
746 )?;
747 write(&dir, &coord, &updated, state.key())?;
748 if lowered {
749 state.audit(
750 AuditAction::SensitivityDowngrade,
751 "downgraded",
752 &updated.canonical_path(),
753 &env,
754 );
755 }
756 state.audit(
757 AuditAction::Edit,
758 "metadata-updated",
759 &updated.canonical_path(),
760 &env,
761 );
762 Ok(Json(json!({ "edited": updated.canonical_path() })))
763}
764
765async fn delete_secret(
767 State(state): State<AppState>,
768 Query(q): Query<CoordQuery>,
769) -> Result<Json<Value>, AppError> {
770 let coord = parse_coord(&q.coord)?;
771 let registry = state.registry()?;
772 let dir = vault_dir(®istry, q.project.as_deref());
773 let existing = store::read_record(&dir, &coord, state.key())
774 .map_err(|e| AppError::internal(e.to_string()))?
775 .ok_or_else(|| AppError::not_found(format!("`{}` not found", q.coord)))?;
776 let canonical = existing.canonical_path();
777 let env = existing.environment().to_string();
778
779 if delete_requires_confirmation(existing.sensitivity()) {
787 let req = ui_action_request(&existing, format!("delete {canonical} (web ui)"));
788 match confirm_action(state.confirmer(), req).await {
789 ConfirmOutcome::Approved => {
790 state.audit(AuditAction::Approve, "approved-delete", &canonical, &env);
791 }
792 ConfirmOutcome::Denied => {
793 state.audit(AuditAction::Deny, "denied-delete", &canonical, &env);
794 return Err(AppError::new(StatusCode::FORBIDDEN, "denied — not deleted"));
795 }
796 ConfirmOutcome::TimedOut => {
797 state.audit(AuditAction::Timeout, "timeout-delete", &canonical, &env);
798 return Err(AppError::new(
799 StatusCode::REQUEST_TIMEOUT,
800 "timed out — not deleted",
801 ));
802 }
803 }
804 }
805
806 store::delete_record(&dir, &coord).map_err(|e| AppError::internal(e.to_string()))?;
807 state.audit(AuditAction::Delete, "deleted", &canonical, &env);
808 Ok(Json(json!({ "deleted": canonical })))
809}
810
811#[derive(Deserialize)]
812struct GenerateBody {
813 coord: String,
814 project: Option<String>,
815 length: Option<usize>,
816 sensitivity: Option<String>,
817 description: Option<String>,
818}
819
820async fn generate_secret(
823 State(state): State<AppState>,
824 Json(body): Json<GenerateBody>,
825) -> Result<Json<Value>, AppError> {
826 let coord = parse_coord(&body.coord)?;
827 let (env, component, key) = segments(&coord);
828 let registry = state.registry()?;
829 let dir = vault_dir(®istry, body.project.as_deref());
830 if store::read_record(&dir, &coord, state.key())
831 .map_err(|e| AppError::internal(e.to_string()))?
832 .is_some()
833 {
834 return Err(AppError::bad(format!("`{}` already exists", body.coord)));
835 }
836 let length = body.length.unwrap_or(32);
837 if length == 0 {
838 return Err(AppError::bad("length must be at least 1"));
839 }
840 use rand::Rng;
841 use rand::distributions::Alphanumeric;
842 let generated: String = rand::rngs::OsRng
843 .sample_iter(&Alphanumeric)
844 .take(length)
845 .map(char::from)
846 .collect();
847 let chosen = parse_sensitivity(body.sensitivity.as_deref())?.unwrap_or(Sensitivity::Medium);
848 let born = birth_sensitivity(&env, chosen);
849 let now = SystemClock.now_rfc3339();
850 let record = SecretRecord::Literal {
851 value: SecretValue::from(generated),
852 sensitivity: born,
853 revealable: false,
854 environment: env.clone(),
855 component,
856 key,
857 description: body.description.clone(),
858 created: now.clone(),
859 updated: now,
860 };
861 write(&dir, &coord, &record, state.key())?;
862 state.audit(
863 AuditAction::Create,
864 "generated",
865 &record.canonical_path(),
866 &env,
867 );
868 Ok(Json(json!({
869 "generated": record.canonical_path(),
870 "length": length,
871 "sensitivity": sensitivity_str(born),
872 "note": "value stored, never returned"
873 })))
874}
875
876const CONFIRM_TIMEOUT: Duration = Duration::from_secs(120);
881
882async fn confirm_action(
887 confirmer: Arc<dyn Confirmer + Send + Sync>,
888 req: ConfirmRequest,
889) -> ConfirmOutcome {
890 tokio::task::spawn_blocking(move || confirmer.confirm(&req, CONFIRM_TIMEOUT))
891 .await
892 .unwrap_or(ConfirmOutcome::Denied)
893}
894
895fn ui_action_request(record: &SecretRecord, command: String) -> ConfirmRequest {
900 ConfirmRequest::new(
901 record.canonical_path(),
902 record.sensitivity(),
903 record.environment().to_string(),
904 Origin::Human,
905 )
906 .with_command(command)
907 .with_requesting_process("kovra ui (web admin)")
909 .with_allow_password(true)
914}
915
916fn parse_coord(s: &str) -> Result<Coordinate, AppError> {
917 let with_scheme = if s.starts_with("secret:") {
918 s.to_string()
919 } else {
920 format!("secret:{s}")
921 };
922 let coord = Coordinate::from_str(&with_scheme).map_err(|e| AppError::bad(e.to_string()))?;
923 coord
925 .canonical_path()
926 .map_err(|e| AppError::bad(format!("{e} (coordinate must be concrete)")))?;
927 Ok(coord)
928}
929
930fn segments(coord: &Coordinate) -> (String, String, String) {
931 use kovra_core::EnvSegment;
932 let env = match &coord.environment {
933 EnvSegment::Literal(e) => e.clone(),
934 EnvSegment::Placeholder => unreachable!("parse_coord rejects placeholders"),
935 };
936 (env, coord.component.clone(), coord.key.clone())
937}
938
939fn vault_dir(registry: &Registry, project: Option<&str>) -> PathBuf {
940 match project {
941 Some(p) => registry.project_dir(p),
942 None => registry.global_dir(),
943 }
944}
945
946fn write(
947 dir: &std::path::Path,
948 coord: &Coordinate,
949 record: &SecretRecord,
950 key: &[u8; kovra_core::KEY_LEN],
951) -> Result<(), AppError> {
952 let sealed = kovra_core::seal(record, key).map_err(|e| AppError::internal(e.to_string()))?;
953 store::write_record(dir, coord, &sealed).map_err(|e| AppError::internal(e.to_string()))
954}
955
956fn sensitivity_str(s: Sensitivity) -> &'static str {
957 match s {
958 Sensitivity::Low => "low",
959 Sensitivity::Medium => "medium",
960 Sensitivity::High => "high",
961 Sensitivity::InjectOnly => "inject-only",
962 }
963}
964
965fn parse_sensitivity(s: Option<&str>) -> Result<Option<Sensitivity>, AppError> {
966 match s {
967 None => Ok(None),
968 Some(v) => match v.to_ascii_lowercase().replace('_', "-").as_str() {
969 "low" => Ok(Some(Sensitivity::Low)),
970 "medium" => Ok(Some(Sensitivity::Medium)),
971 "high" => Ok(Some(Sensitivity::High)),
972 "inject-only" => Ok(Some(Sensitivity::InjectOnly)),
973 other => Err(AppError::bad(format!("unknown sensitivity `{other}`"))),
974 },
975 }
976}
977
978fn apply_edit(
979 existing: SecretRecord,
980 new_sensitivity: Option<Sensitivity>,
981 new_description: Option<String>,
982 new_reference: Option<String>,
983 new_revealable: Option<bool>,
984 now: String,
985) -> Result<SecretRecord, AppError> {
986 match existing {
987 SecretRecord::Literal {
988 value,
989 sensitivity,
990 revealable,
991 environment,
992 component,
993 key,
994 description,
995 created,
996 ..
997 } => {
998 if new_reference.is_some() {
999 return Err(AppError::bad(
1000 "`reference` edits a reference secret; this is a literal",
1001 ));
1002 }
1003 Ok(SecretRecord::Literal {
1004 value,
1005 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1006 revealable: new_revealable.unwrap_or(revealable),
1007 environment,
1008 component,
1009 key,
1010 description: new_description.or(description),
1011 created,
1012 updated: now,
1013 })
1014 }
1015 SecretRecord::Reference {
1016 reference,
1017 sensitivity,
1018 revealable,
1019 environment,
1020 component,
1021 key,
1022 description,
1023 created,
1024 ..
1025 } => Ok(SecretRecord::Reference {
1026 reference: new_reference.unwrap_or(reference),
1027 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1028 revealable: new_revealable.unwrap_or(revealable),
1029 environment,
1030 component,
1031 key,
1032 description: new_description.or(description),
1033 created,
1034 updated: now,
1035 }),
1036 SecretRecord::Keypair {
1037 algorithm,
1038 private,
1039 public,
1040 sensitivity,
1041 revealable,
1042 environment,
1043 component,
1044 key,
1045 description,
1046 created,
1047 ..
1048 } => {
1049 if new_reference.is_some() {
1050 return Err(AppError::bad(
1051 "`reference` edits a reference secret; this is a keypair",
1052 ));
1053 }
1054 Ok(SecretRecord::Keypair {
1055 algorithm,
1056 private,
1057 public,
1058 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1059 revealable: new_revealable.unwrap_or(revealable),
1060 environment,
1061 component,
1062 key,
1063 description: new_description.or(description),
1064 created,
1065 updated: now,
1066 })
1067 }
1068 SecretRecord::Totp {
1069 seed,
1070 algorithm,
1071 digits,
1072 period,
1073 sensitivity,
1074 revealable,
1075 environment,
1076 component,
1077 key,
1078 description,
1079 created,
1080 ..
1081 } => {
1082 if new_reference.is_some() {
1083 return Err(AppError::bad(
1084 "`reference` edits a reference secret; this is a TOTP enrollment",
1085 ));
1086 }
1087 Ok(SecretRecord::Totp {
1088 seed,
1089 algorithm,
1090 digits,
1091 period,
1092 sensitivity: new_sensitivity.unwrap_or(sensitivity),
1093 revealable: new_revealable.unwrap_or(revealable),
1094 environment,
1095 component,
1096 key,
1097 description: new_description.or(description),
1098 created,
1099 updated: now,
1100 })
1101 }
1102 }
1103}
1104
1105pub async fn serve(
1111 listener: tokio::net::TcpListener,
1112 state: AppState,
1113 idle: Duration,
1114) -> std::io::Result<()> {
1115 let app = build_app(state.clone());
1116 axum::serve(listener, app)
1117 .with_graceful_shutdown(shutdown_signal(state, idle))
1118 .await
1119}
1120
1121async fn shutdown_signal(state: AppState, idle: Duration) {
1123 let ctrl_c = async {
1124 let _ = tokio::signal::ctrl_c().await;
1125 };
1126 let idle_watchdog = async {
1127 let tick = Duration::from_secs(5).min(idle);
1128 loop {
1129 tokio::time::sleep(tick).await;
1130 if state.idle_for() >= idle {
1131 break;
1132 }
1133 }
1134 };
1135 tokio::select! {
1136 _ = ctrl_c => {}
1137 _ = idle_watchdog => {}
1138 }
1139}
1140
1141pub fn default_addr(port: u16) -> SocketAddr {
1143 SocketAddr::from(([127, 0, 0, 1], port))
1144}
1145
1146pub fn parse_master_key(raw: &[u8]) -> Result<MasterKey, String> {
1153 if raw.len() == kovra_core::KEY_LEN {
1155 let mut key = [0u8; kovra_core::KEY_LEN];
1156 key.copy_from_slice(raw);
1157 return Ok(MasterKey::new(key));
1158 }
1159 let text = std::str::from_utf8(raw)
1161 .map_err(|_| "master key file is neither raw bytes nor UTF-8 hex".to_string())?
1162 .trim();
1163 if text.len() != kovra_core::KEY_LEN * 2 {
1164 return Err(format!(
1165 "master key must be {} raw bytes or {} hex chars (got {} chars)",
1166 kovra_core::KEY_LEN,
1167 kovra_core::KEY_LEN * 2,
1168 text.len()
1169 ));
1170 }
1171 let mut key = [0u8; kovra_core::KEY_LEN];
1172 for (i, pair) in text.as_bytes().chunks(2).enumerate() {
1173 let hi = (pair[0] as char)
1174 .to_digit(16)
1175 .ok_or_else(|| "master key hex is invalid".to_string())?;
1176 let lo = (pair[1] as char)
1177 .to_digit(16)
1178 .ok_or_else(|| "master key hex is invalid".to_string())?;
1179 key[i] = (hi * 16 + lo) as u8;
1180 }
1181 Ok(MasterKey::new(key))
1182}
1183
1184const INDEX_HTML: &str = r##"<!doctype html>
1190<html lang="en" data-theme="dark"><head>
1191<meta charset="utf-8"><title>kovra — local admin</title>
1192<meta name="viewport" content="width=device-width, initial-scale=1">
1193<link rel="icon" href="/assets/kovra-icon.png">
1194<link rel="stylesheet" href="/assets/tabulator/tabulator.min.css">
1195<link rel="stylesheet" href="/assets/app.css">
1196</head><body>
1197<div class="app">
1198 <aside class="side">
1199 <div class="brand">
1200 <div class="logo"><img src="/assets/kovra-icon.png" alt="kovra"></div>
1201 <div><div class="name">ko<span class="v">v</span>ra</div><div class="tag">local secrets</div></div>
1202 </div>
1203 <nav class="nav">
1204 <a class="on" href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 11 4.6-2.5 8-6 8-11V5l-8-3Z"/></svg>Secrets</a>
1205 </nav>
1206 <div class="spacer"></div>
1207 <div class="vault"><span class="dot"></span><div><div class="who">local vault</div><div class="sub">loopback only</div></div></div>
1208 </aside>
1209 <div class="main">
1210 <div class="top">
1211 <div class="search">
1212 <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>
1213 <input id="search" type="search" placeholder="Search secrets, coordinates, projects…" autocomplete="off" spellcheck="false">
1214 </div>
1215 <span class="looppill"><span class="d"></span>loopback</span>
1216 <button class="iconbtn" id="refresh" title="Refresh">
1217 <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>
1218 </button>
1219 <button class="iconbtn" id="theme" title="Toggle theme">
1220 <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>
1221 </button>
1222 </div>
1223 <div class="content">
1224 <div class="head">
1225 <div><h1>Secrets</h1><div class="sub"><span id="status">loading…</span> · governed by sensitivity · loopback only</div></div>
1226 <div class="right">
1227 <div class="seg">
1228 <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>
1229 <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>
1230 <button id="view-projects"><svg 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>Projects</button>
1231 </div>
1232 <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>
1233 </div>
1234 </div>
1235 <div class="stats">
1236 <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>
1237 <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>
1238 <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>
1239 <div class="stat"><div class="n" id="stat-ref">—</div><div class="l"><span class="d" style="background:var(--med)"></span>references</div></div>
1240 </div>
1241 <div class="project-bar" id="project-bar" hidden></div>
1242 <div class="card"><div id="grid"></div></div>
1243 </div>
1244 </div>
1245</div>
1246
1247<div class="scrim" id="scrim"></div>
1248<aside class="drawer" id="drawer">
1249 <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>
1250 <div class="db" id="reveal-body"></div>
1251</aside>
1252
1253<dialog id="form">
1254 <form id="form-el">
1255 <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>
1256 <div class="mb" id="form-body"></div>
1257 <div class="mf">
1258 <button type="button" id="form-cancel-2" class="btn">Cancel</button>
1259 <button type="submit" id="form-submit" class="btn primary">Save</button>
1260 </div>
1261 </form>
1262</dialog>
1263<div id="toasts" aria-live="polite"></div>
1264<script src="/assets/tabulator/tabulator.min.js"></script>
1265<script src="/assets/app.js"></script>
1266</body></html>"##;
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271 use axum::body::Body;
1272 use axum::http::Request;
1273 use kovra_core::MockConfirmer;
1274 use tower::ServiceExt; const KEY: [u8; kovra_core::KEY_LEN] = [0x33; kovra_core::KEY_LEN];
1277
1278 fn state_with_confirmer(outcome: ConfirmOutcome) -> (AppState, tempfile::TempDir) {
1282 let dir = tempfile::tempdir().unwrap();
1283 Registry::open(dir.path()).unwrap();
1285 let state = AppState::new(
1286 dir.path().to_path_buf(),
1287 MasterKey::new(KEY),
1288 Arc::new(MockConfirmer::always(outcome)),
1289 );
1290 (state, dir)
1291 }
1292
1293 fn temp_state() -> (AppState, tempfile::TempDir) {
1296 state_with_confirmer(ConfirmOutcome::Approved)
1297 }
1298
1299 fn put_record(state: &AppState, record: &SecretRecord) {
1300 let registry = state.registry().unwrap();
1301 let coord = Coordinate::from_str(&format!("secret:{}", record.canonical_path())).unwrap();
1302 write(®istry.global_dir(), &coord, record, state.key()).unwrap();
1303 }
1304
1305 fn read_back(state: &AppState, coord: &str) -> Option<SecretRecord> {
1306 let c = Coordinate::from_str(&format!("secret:{coord}")).unwrap();
1307 store::read_record(&state.registry().unwrap().global_dir(), &c, state.key()).unwrap()
1308 }
1309
1310 fn api_patch(body: &str, session: &str) -> Request<Body> {
1311 Request::builder()
1312 .method("PATCH")
1313 .uri("/api/secret")
1314 .header(header::HOST, "127.0.0.1:8731")
1315 .header(SESSION_HEADER, session)
1316 .header(header::CONTENT_TYPE, "application/json")
1317 .body(Body::from(body.to_string()))
1318 .unwrap()
1319 }
1320
1321 fn api_delete(coord: &str, session: &str) -> Request<Body> {
1322 Request::builder()
1323 .method("DELETE")
1324 .uri(format!("/api/secret?coord={coord}"))
1325 .header(header::HOST, "127.0.0.1:8731")
1326 .header(SESSION_HEADER, session)
1327 .body(Body::empty())
1328 .unwrap()
1329 }
1330
1331 fn literal(env: &str, key: &str, value: &str, sens: Sensitivity) -> SecretRecord {
1332 SecretRecord::Literal {
1333 value: SecretValue::from(value),
1334 sensitivity: sens,
1335 revealable: false,
1336 environment: env.to_string(),
1337 component: "app".to_string(),
1338 key: key.to_string(),
1339 description: None,
1340 created: "2026-06-01T00:00:00Z".to_string(),
1341 updated: "2026-06-01T00:00:00Z".to_string(),
1342 }
1343 }
1344
1345 async fn body_json(resp: Response) -> Value {
1346 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1347 .await
1348 .unwrap();
1349 serde_json::from_slice(&bytes).unwrap_or(Value::Null)
1350 }
1351
1352 fn api_get(uri: &str, session: &str) -> Request<Body> {
1353 Request::builder()
1354 .method("GET")
1355 .uri(uri)
1356 .header(header::HOST, "127.0.0.1:8731")
1357 .header(SESSION_HEADER, session)
1358 .body(Body::empty())
1359 .unwrap()
1360 }
1361
1362 #[tokio::test]
1364 async fn medium_literal_reveals_value() {
1365 let (state, _d) = temp_state();
1366 put_record(
1367 &state,
1368 &literal("dev", "url", "postgres://x", Sensitivity::Medium),
1369 );
1370 let app = build_app(state.clone());
1371 let resp = app
1372 .oneshot(api_get(
1373 "/api/reveal?coord=dev/app/url",
1374 state.session_token(),
1375 ))
1376 .await
1377 .unwrap();
1378 assert_eq!(resp.status(), StatusCode::OK);
1379 let j = body_json(resp).await;
1380 assert_eq!(j["value"], "postgres://x");
1381 }
1382
1383 #[tokio::test]
1385 async fn high_literal_is_masked_never_value() {
1386 let (state, _d) = temp_state();
1387 put_record(
1388 &state,
1389 &literal("dev", "key", "TOP-SECRET-HIGH", Sensitivity::High),
1390 );
1391 let app = build_app(state.clone());
1392 let resp = app
1393 .oneshot(api_get(
1394 "/api/reveal?coord=dev/app/key",
1395 state.session_token(),
1396 ))
1397 .await
1398 .unwrap();
1399 let j = body_json(resp).await;
1400 assert_eq!(j["masked"], json!(true));
1401 assert!(j.get("value").is_none(), "high must not return a value");
1402 assert!(j["fingerprint"].is_string());
1403 assert!(
1405 !serde_json::to_string(&j)
1406 .unwrap()
1407 .contains("TOP-SECRET-HIGH")
1408 );
1409 }
1410
1411 #[tokio::test]
1413 async fn inject_only_returns_metadata_only() {
1414 let (state, _d) = temp_state();
1415 put_record(
1416 &state,
1417 &literal("dev", "tok", "INJECT-ONLY-VAL", Sensitivity::InjectOnly),
1418 );
1419 let app = build_app(state.clone());
1420 let resp = app
1421 .oneshot(api_get(
1422 "/api/reveal?coord=dev/app/tok",
1423 state.session_token(),
1424 ))
1425 .await
1426 .unwrap();
1427 let j = body_json(resp).await;
1428 assert_eq!(j["inject_only"], json!(true));
1429 assert!(j.get("value").is_none());
1430 assert!(
1431 !serde_json::to_string(&j)
1432 .unwrap()
1433 .contains("INJECT-ONLY-VAL")
1434 );
1435 }
1436
1437 #[tokio::test]
1439 async fn reference_reveals_pointer_only() {
1440 let (state, _d) = temp_state();
1441 put_record(
1442 &state,
1443 &SecretRecord::Reference {
1444 reference: "azure-kv://corp-kv/api".to_string(),
1445 sensitivity: Sensitivity::High,
1446 revealable: false,
1447 environment: "dev".to_string(),
1448 component: "app".to_string(),
1449 key: "api".to_string(),
1450 description: None,
1451 created: "2026-06-01T00:00:00Z".to_string(),
1452 updated: "2026-06-01T00:00:00Z".to_string(),
1453 },
1454 );
1455 let app = build_app(state.clone());
1456 let resp = app
1457 .oneshot(api_get(
1458 "/api/reveal?coord=dev/app/api",
1459 state.session_token(),
1460 ))
1461 .await
1462 .unwrap();
1463 let j = body_json(resp).await;
1464 assert_eq!(j["kind"], "reference");
1465 assert_eq!(j["pointer"], "azure-kv://corp-kv/api");
1466 assert!(j.get("value").is_none());
1467 }
1468
1469 #[tokio::test]
1471 async fn listing_is_metadata_only() {
1472 let (state, _d) = temp_state();
1473 put_record(
1474 &state,
1475 &literal("dev", "url", "secret-listing-value", Sensitivity::Medium),
1476 );
1477 let app = build_app(state.clone());
1478 let resp = app
1479 .oneshot(api_get("/api/secrets", state.session_token()))
1480 .await
1481 .unwrap();
1482 let j = body_json(resp).await;
1483 let txt = serde_json::to_string(&j).unwrap();
1484 assert!(txt.contains("dev/app/url"));
1485 assert!(
1486 !txt.contains("secret-listing-value"),
1487 "listing must not carry values"
1488 );
1489 }
1490
1491 #[tokio::test]
1493 async fn api_requires_session_token() {
1494 let (state, _d) = temp_state();
1495 let app = build_app(state.clone());
1496 let resp = app
1497 .oneshot(api_get("/api/secrets", "wrong-token"))
1498 .await
1499 .unwrap();
1500 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1501 }
1502
1503 #[tokio::test]
1505 async fn non_loopback_host_is_rejected() {
1506 let (state, _d) = temp_state();
1507 let app = build_app(state.clone());
1508 let req = Request::builder()
1509 .method("GET")
1510 .uri("/api/secrets")
1511 .header(header::HOST, "evil.example.com")
1512 .header(SESSION_HEADER, state.session_token())
1513 .body(Body::empty())
1514 .unwrap();
1515 let resp = app.oneshot(req).await.unwrap();
1516 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1517 }
1518
1519 #[tokio::test]
1521 async fn cross_origin_is_rejected() {
1522 let (state, _d) = temp_state();
1523 let app = build_app(state.clone());
1524 let req = Request::builder()
1525 .method("GET")
1526 .uri("/api/secrets")
1527 .header(header::HOST, "127.0.0.1:8731")
1528 .header(header::ORIGIN, "http://evil.example.com")
1529 .header(SESSION_HEADER, state.session_token())
1530 .body(Body::empty())
1531 .unwrap();
1532 let resp = app.oneshot(req).await.unwrap();
1533 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1534 }
1535
1536 #[tokio::test]
1538 async fn crud_round_trip() {
1539 let (state, _d) = temp_state();
1540 let app = build_app(state.clone());
1541 let body = json!({"coord":"dev/app/new","value":"v1","sensitivity":"medium"}).to_string();
1543 let req = Request::builder()
1544 .method("POST")
1545 .uri("/api/secret")
1546 .header(header::HOST, "127.0.0.1:8731")
1547 .header(SESSION_HEADER, state.session_token())
1548 .header(header::CONTENT_TYPE, "application/json")
1549 .body(Body::from(body))
1550 .unwrap();
1551 let resp = app.clone().oneshot(req).await.unwrap();
1552 assert_eq!(resp.status(), StatusCode::OK, "create failed");
1553 let resp = build_app(state.clone())
1555 .oneshot(api_get(
1556 "/api/reveal?coord=dev/app/new",
1557 state.session_token(),
1558 ))
1559 .await
1560 .unwrap();
1561 assert_eq!(body_json(resp).await["value"], "v1");
1562 let req = Request::builder()
1564 .method("DELETE")
1565 .uri("/api/secret?coord=dev/app/new")
1566 .header(header::HOST, "127.0.0.1:8731")
1567 .header(SESSION_HEADER, state.session_token())
1568 .body(Body::empty())
1569 .unwrap();
1570 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
1571 assert_eq!(resp.status(), StatusCode::OK);
1572 }
1573
1574 #[tokio::test]
1577 async fn downgrade_of_high_denied_leaves_record_unchanged() {
1578 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1579 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1580 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
1581 let resp = build_app(state.clone())
1582 .oneshot(api_patch(&body, state.session_token()))
1583 .await
1584 .unwrap();
1585 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1586 assert_eq!(
1587 read_back(&state, "dev/app/key").unwrap().sensitivity(),
1588 Sensitivity::High,
1589 "denied downgrade must not lower sensitivity"
1590 );
1591 }
1592
1593 #[tokio::test]
1595 async fn downgrade_of_high_approved_lowers_sensitivity() {
1596 let (state, _d) = state_with_confirmer(ConfirmOutcome::Approved);
1597 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1598 let body = json!({"coord":"dev/app/key","sensitivity":"low"}).to_string();
1599 let resp = build_app(state.clone())
1600 .oneshot(api_patch(&body, state.session_token()))
1601 .await
1602 .unwrap();
1603 assert_eq!(resp.status(), StatusCode::OK);
1604 assert_eq!(
1605 read_back(&state, "dev/app/key").unwrap().sensitivity(),
1606 Sensitivity::Low
1607 );
1608 }
1609
1610 #[tokio::test]
1613 async fn noncritical_downgrade_is_not_gated() {
1614 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1615 put_record(&state, &literal("dev", "url", "v", Sensitivity::Medium));
1616 let body = json!({"coord":"dev/app/url","sensitivity":"low"}).to_string();
1617 let resp = build_app(state.clone())
1618 .oneshot(api_patch(&body, state.session_token()))
1619 .await
1620 .unwrap();
1621 assert_eq!(resp.status(), StatusCode::OK);
1622 assert_eq!(
1623 read_back(&state, "dev/app/url").unwrap().sensitivity(),
1624 Sensitivity::Low
1625 );
1626 }
1627
1628 #[tokio::test]
1631 async fn delete_of_high_denied_keeps_record() {
1632 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1633 put_record(&state, &literal("dev", "key", "v", Sensitivity::High));
1634 let resp = build_app(state.clone())
1635 .oneshot(api_delete("dev/app/key", state.session_token()))
1636 .await
1637 .unwrap();
1638 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1639 assert!(
1640 read_back(&state, "dev/app/key").is_some(),
1641 "denied delete of a critical secret must keep the record"
1642 );
1643 }
1644
1645 #[tokio::test]
1649 async fn delete_of_low_is_not_broker_gated() {
1650 let (state, _d) = state_with_confirmer(ConfirmOutcome::Denied);
1651 put_record(&state, &literal("dev", "url", "v", Sensitivity::Low));
1652 let resp = build_app(state.clone())
1653 .oneshot(api_delete("dev/app/url", state.session_token()))
1654 .await
1655 .unwrap();
1656 assert_eq!(resp.status(), StatusCode::OK);
1657 assert!(
1658 read_back(&state, "dev/app/url").is_none(),
1659 "non-critical delete must not consult the broker"
1660 );
1661 }
1662
1663 #[test]
1666 fn master_key_parses_raw_and_hex() {
1667 let raw = [0x33u8; kovra_core::KEY_LEN];
1668 let from_raw = parse_master_key(&raw).unwrap();
1669 assert_eq!(from_raw.expose(), &raw);
1670
1671 let hex: String = raw.iter().map(|b| format!("{b:02x}")).collect();
1672 let from_hex = parse_master_key(hex.as_bytes()).unwrap();
1673 assert_eq!(from_hex.expose(), &raw);
1674
1675 let from_hex_nl = parse_master_key(format!("{hex}\n").as_bytes()).unwrap();
1677 assert_eq!(from_hex_nl.expose(), &raw);
1678
1679 assert!(parse_master_key(b"too-short").is_err());
1681 assert!(parse_master_key(&[0u8; kovra_core::KEY_LEN - 1]).is_err());
1682 let bad_hex = "z".repeat(kovra_core::KEY_LEN * 2);
1683 assert!(parse_master_key(bad_hex.as_bytes()).is_err());
1684 }
1685
1686 #[tokio::test]
1688 async fn generate_never_returns_value_and_prod_is_high() {
1689 let (state, _d) = temp_state();
1690 let body = json!({"coord":"prod/app/gen","length":24}).to_string();
1691 let req = Request::builder()
1692 .method("POST")
1693 .uri("/api/generate")
1694 .header(header::HOST, "127.0.0.1:8731")
1695 .header(SESSION_HEADER, state.session_token())
1696 .header(header::CONTENT_TYPE, "application/json")
1697 .body(Body::from(body))
1698 .unwrap();
1699 let resp = build_app(state.clone()).oneshot(req).await.unwrap();
1700 let j = body_json(resp).await;
1701 assert_eq!(j["sensitivity"], "high", "prod born high (I5)");
1702 assert!(j.get("value").is_none(), "generate never returns the value");
1703 let resp = build_app(state.clone())
1705 .oneshot(api_get(
1706 "/api/reveal?coord=prod/app/gen",
1707 state.session_token(),
1708 ))
1709 .await
1710 .unwrap();
1711 assert_eq!(body_json(resp).await["masked"], json!(true));
1712 }
1713
1714 async fn body_text(resp: Response) -> (StatusCode, String, String) {
1717 let status = resp.status();
1718 let ctype = resp
1719 .headers()
1720 .get(header::CONTENT_TYPE)
1721 .and_then(|v| v.to_str().ok())
1722 .unwrap_or_default()
1723 .to_string();
1724 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1725 .await
1726 .unwrap();
1727 (status, ctype, String::from_utf8_lossy(&bytes).into_owned())
1728 }
1729
1730 fn get_loopback(uri: &str, host: &str) -> Request<Body> {
1731 Request::builder()
1732 .method("GET")
1733 .uri(uri)
1734 .header(header::HOST, host)
1735 .body(Body::empty())
1736 .unwrap()
1737 }
1738
1739 #[tokio::test]
1742 async fn index_shell_references_assets_and_has_no_inline_logic() {
1743 let (state, _d) = temp_state();
1744 let resp = build_app(state.clone())
1745 .oneshot(get_loopback("/", "127.0.0.1:8731"))
1746 .await
1747 .unwrap();
1748 let (status, _ct, html) = body_text(resp).await;
1749 assert_eq!(status, StatusCode::OK);
1750 assert!(html.contains(r#"src="/assets/tabulator/tabulator.min.js""#));
1751 assert!(html.contains(r#"src="/assets/app.js""#));
1752 assert!(html.contains(r#"<div id="grid">"#));
1753 assert!(
1755 !html.contains("fetch('/api/secrets'") && !html.contains("/api/reveal?"),
1756 "shell must not embed inline API logic"
1757 );
1758 }
1759
1760 #[tokio::test]
1762 async fn embedded_assets_are_served_with_types() {
1763 let (state, _d) = temp_state();
1764 let cases = [
1765 (
1766 "/assets/tabulator/tabulator.min.js",
1767 "javascript",
1768 "Tabulator",
1769 ),
1770 (
1771 "/assets/tabulator/tabulator.min.css",
1772 "text/css",
1773 ".tabulator",
1774 ),
1775 ("/assets/app.js", "javascript", "kovra Web UI v2"),
1776 ("/assets/app.css", "text/css", "kovra Web UI v2"),
1777 ];
1778 for (uri, want_ct, want_body) in cases {
1779 let resp = build_app(state.clone())
1780 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
1781 .await
1782 .unwrap();
1783 let (status, ct, body) = body_text(resp).await;
1784 assert_eq!(status, StatusCode::OK, "{uri}");
1785 assert!(ct.contains(want_ct), "{uri} content-type was `{ct}`");
1786 assert!(body.contains(want_body), "{uri} body missing `{want_body}`");
1787 }
1788 }
1789
1790 #[tokio::test]
1793 async fn embedded_brand_binary_assets_are_served() {
1794 let (state, _d) = temp_state();
1795 let cases = [
1796 ("/assets/kovra-icon.png", "image/png"),
1797 ("/assets/fonts/sora-latin-600-normal.woff2", "font/woff2"),
1798 ("/assets/fonts/inter-latin-400-normal.woff2", "font/woff2"),
1799 ("/assets/fonts/inter-latin-500-normal.woff2", "font/woff2"),
1800 ("/assets/fonts/inter-latin-600-normal.woff2", "font/woff2"),
1801 ];
1802 for (uri, want_ct) in cases {
1803 let resp = build_app(state.clone())
1804 .oneshot(get_loopback(uri, "127.0.0.1:8731"))
1805 .await
1806 .unwrap();
1807 let status = resp.status();
1808 let ct = resp
1809 .headers()
1810 .get(header::CONTENT_TYPE)
1811 .and_then(|v| v.to_str().ok())
1812 .unwrap_or_default()
1813 .to_string();
1814 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1815 .await
1816 .unwrap();
1817 assert_eq!(status, StatusCode::OK, "{uri}");
1818 assert_eq!(ct, want_ct, "{uri} content-type");
1819 assert!(!bytes.is_empty(), "{uri} body is empty");
1820 }
1821 }
1822
1823 #[tokio::test]
1826 async fn index_shell_has_brand_chrome() {
1827 let (state, _d) = temp_state();
1828 let resp = build_app(state.clone())
1829 .oneshot(get_loopback("/", "127.0.0.1:8731"))
1830 .await
1831 .unwrap();
1832 let (status, _ct, html) = body_text(resp).await;
1833 assert_eq!(status, StatusCode::OK);
1834 assert!(html.contains(r#"href="/assets/kovra-icon.png""#));
1835 assert!(html.contains(r#"id="theme""#));
1836 assert!(html.contains(r#"id="drawer""#));
1837 assert!(html.contains(r#"id="stat-total""#));
1838 assert!(html.contains(r#"id="view-table""#));
1840 assert!(html.contains(r#"id="view-tree""#));
1841 assert!(html.contains(r#"id="view-projects""#));
1842 }
1843
1844 #[tokio::test]
1847 async fn assets_need_no_session_but_are_loopback_guarded() {
1848 let (state, _d) = temp_state();
1849 let resp = build_app(state.clone())
1851 .oneshot(get_loopback("/assets/app.js", "127.0.0.1:8731"))
1852 .await
1853 .unwrap();
1854 assert_eq!(resp.status(), StatusCode::OK);
1855 let resp = build_app(state.clone())
1857 .oneshot(get_loopback("/assets/app.js", "evil.example.com"))
1858 .await
1859 .unwrap();
1860 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1861 }
1862
1863 #[tokio::test]
1866 async fn api_secrets_contract_is_metadata_only() {
1867 let (state, _d) = temp_state();
1868 put_record(
1869 &state,
1870 &literal(
1871 "dev",
1872 "url",
1873 "should-not-appear-in-listing",
1874 Sensitivity::Medium,
1875 ),
1876 );
1877 let resp = build_app(state.clone())
1878 .oneshot(api_get("/api/secrets", state.session_token()))
1879 .await
1880 .unwrap();
1881 let j = body_json(resp).await;
1882 let row = &j["secrets"][0];
1883 for k in ["coordinate", "sensitivity", "mode", "fingerprint"] {
1884 assert!(row.get(k).is_some(), "row missing `{k}`");
1885 }
1886 assert!(
1887 row.get("value").is_none(),
1888 "listing must never carry a value"
1889 );
1890 let txt = serde_json::to_string(&j).unwrap();
1891 assert!(!txt.contains("should-not-appear-in-listing"));
1892 }
1893}