create-ekka-desktop-app 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ekka-desktop-app",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Create an EKKA desktop app with built-in demo backend. No setup required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -763,8 +763,7 @@ fn handle_bootstrap_node_session(payload: &Value, state: &EngineState) -> Engine
763
763
  Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
764
764
  };
765
765
 
766
- // REQUIRE node auth token (from startup auth via node_id + node_secret)
767
- // Do NOT fall back to user auth or Ed25519 flow
766
+ // Get node auth token - try auto-auth if not available
768
767
  let node_token = match state.get_node_auth_token() {
769
768
  Some(token) => {
770
769
  tracing::info!(
@@ -776,14 +775,55 @@ fn handle_bootstrap_node_session(payload: &Value, state: &EngineState) -> Engine
776
775
  token
777
776
  }
778
777
  None => {
779
- tracing::error!(
780
- op = "node_session.no_token",
781
- "Node auth token not available - authenticate node at startup first"
782
- );
783
- return EngineResponse::err(
784
- "NODE_NOT_AUTHENTICATED",
785
- "Node not authenticated. Restart app with valid node credentials.",
778
+ // Token missing - try auto-auth from vault (single-flight)
779
+ // Check prerequisites BEFORE acquiring lock
780
+ if !node_credentials::has_credentials() {
781
+ tracing::error!(
782
+ op = "node_session.no_credentials",
783
+ "Node credentials not configured"
784
+ );
785
+ return EngineResponse::err(
786
+ "NODE_CREDENTIALS_MISSING",
787
+ "Node credentials not configured. Complete setup first.",
788
+ );
789
+ }
790
+
791
+ // Get engine URL from baked config (same source as everywhere else)
792
+ let engine_url = config::engine_url();
793
+
794
+ // Now acquire single-flight lock (after all prerequisite checks)
795
+ if !state.node_auth_state.try_start() {
796
+ return EngineResponse::err("NODE_AUTH_IN_PROGRESS", "Authentication in progress, please wait");
797
+ }
798
+
799
+ // From here, ALL paths must call set_authenticated() or set_failed()
800
+ tracing::info!(
801
+ op = "node_session.auto_auth",
802
+ "Auto-authenticating node after setup"
786
803
  );
804
+
805
+ match node_credentials::authenticate_node(engine_url) {
806
+ Ok(token) => {
807
+ state.node_auth_token.set(token.clone());
808
+ state.node_auth_state.set_authenticated();
809
+ tracing::info!(
810
+ op = "node_session.auto_auth_success",
811
+ node_id = %token.node_id,
812
+ "Node auto-authenticated successfully"
813
+ );
814
+ token
815
+ }
816
+ Err(e) => {
817
+ let error_msg = format!("Node authentication failed: {}", e);
818
+ tracing::error!(
819
+ op = "node_session.auto_auth_failed",
820
+ error = %e,
821
+ "Node auto-authentication failed"
822
+ );
823
+ state.node_auth_state.set_failed(error_msg.clone());
824
+ return EngineResponse::err("NODE_NOT_AUTHENTICATED", &error_msg);
825
+ }
826
+ }
787
827
  }
788
828
  };
789
829
 
@@ -19,6 +19,7 @@ mod node_runner;
19
19
  mod node_vault_crypto;
20
20
  mod node_vault_store;
21
21
  mod ops;
22
+ mod security_epoch;
22
23
  mod state;
23
24
  mod types;
24
25
 
@@ -31,7 +32,8 @@ use tauri::Manager;
31
32
 
32
33
  fn main() {
33
34
  // Load .env.local for development (before anything else)
34
- // This provides EKKA_SECURITY_EPOCH and other dev-time overrides.
35
+ // This provides optional dev-time overrides (EKKA_SECURITY_EPOCH, etc).
36
+ // Note: EKKA_SECURITY_EPOCH is optional - epoch is resolved from marker file by default.
35
37
  // Note: ENGINE_GRANT_VERIFY_KEY_B64 is now fetched from /.well-known/ekka-configuration
36
38
  if let Err(_) = dotenvy::from_filename(".env.local") {
37
39
  // Also try parent directory (when running from src-tauri)
@@ -63,14 +65,6 @@ fn main() {
63
65
  // Attempt to spawn engine process
64
66
  tracing::info!(op = "desktop.startup", "EKKA Desktop starting");
65
67
 
66
- // Log security epoch status (still needed for home bootstrap)
67
- let security_epoch_set = std::env::var("EKKA_SECURITY_EPOCH").is_ok();
68
- tracing::info!(
69
- op = "desktop.required_env.loaded",
70
- EKKA_SECURITY_EPOCH = security_epoch_set,
71
- "Security epoch env var status"
72
- );
73
-
74
68
  // Log build-time baked engine URL presence (not the URL itself)
75
69
  let engine_url_baked = option_env!("EKKA_ENGINE_URL").is_some();
76
70
  tracing::info!(
@@ -16,6 +16,7 @@ use crate::node_vault_store::{
16
16
  delete_node_secret, has_node_secret, read_node_secret, write_node_secret,
17
17
  SECRET_ID_NODE_CREDENTIALS,
18
18
  };
19
+ use crate::security_epoch::resolve_security_epoch;
19
20
  use chrono::{DateTime, Utc};
20
21
  use serde::{Deserialize, Serialize};
21
22
  use std::sync::RwLock;
@@ -134,17 +135,6 @@ impl std::fmt::Display for CredentialsError {
134
135
 
135
136
  impl std::error::Error for CredentialsError {}
136
137
 
137
- // =============================================================================
138
- // Helper: Get epoch for vault operations
139
- // =============================================================================
140
-
141
- /// Get the security epoch from environment
142
- fn get_security_epoch() -> u32 {
143
- std::env::var("EKKA_SECURITY_EPOCH")
144
- .ok()
145
- .and_then(|s| s.parse().ok())
146
- .unwrap_or(1)
147
- }
148
138
 
149
139
  // =============================================================================
150
140
  // Core Functions
@@ -170,7 +160,7 @@ pub fn store_credentials(node_id: &Uuid, node_secret: &str) -> Result<(), Creden
170
160
  // Initialize home if needed
171
161
  let bootstrap = initialize_home().map_err(|e| CredentialsError::VaultError(e))?;
172
162
  let home = bootstrap.home_path();
173
- let epoch = get_security_epoch();
163
+ let epoch = resolve_security_epoch(home);
174
164
 
175
165
  // Store node_id + node_secret as JSON
176
166
  let creds = NodeCredentials {
@@ -202,7 +192,7 @@ pub fn store_credentials(node_id: &Uuid, node_secret: &str) -> Result<(), Creden
202
192
  /// * `Err(CredentialsError)` if not found or invalid
203
193
  pub fn load_credentials() -> Result<(Uuid, String), CredentialsError> {
204
194
  let home = resolve_home_path().map_err(|e| CredentialsError::VaultError(e))?;
205
- let epoch = get_security_epoch();
195
+ let epoch = resolve_security_epoch(&home);
206
196
 
207
197
  // Key derivation uses device_secret + epoch only (not node_id)
208
198
  let plaintext = read_node_secret(&home, epoch, SECRET_ID_NODE_CREDENTIALS)
@@ -0,0 +1,128 @@
1
+ //! Security Epoch Resolution
2
+ //!
3
+ //! Resolves the security epoch used for vault key derivation.
4
+ //! Resolution order:
5
+ //! 1. EKKA_SECURITY_EPOCH env var (override for dev/testing)
6
+ //! 2. Marker file epoch_seen field (canonical source)
7
+ //! 3. Default to 1 (new installations)
8
+
9
+ use std::path::Path;
10
+ use std::sync::OnceLock;
11
+
12
+ /// Cached resolved epoch (env var source only - marker is read fresh)
13
+ static ENV_EPOCH_CACHE: OnceLock<Option<u32>> = OnceLock::new();
14
+
15
+ /// Source of the resolved epoch
16
+ #[derive(Debug, Clone, Copy, PartialEq)]
17
+ pub enum EpochSource {
18
+ Env,
19
+ Marker,
20
+ Default,
21
+ }
22
+
23
+ impl std::fmt::Display for EpochSource {
24
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25
+ match self {
26
+ EpochSource::Env => write!(f, "env"),
27
+ EpochSource::Marker => write!(f, "marker"),
28
+ EpochSource::Default => write!(f, "default"),
29
+ }
30
+ }
31
+ }
32
+
33
+ /// Resolve security epoch from env var
34
+ fn try_env_epoch() -> Option<u32> {
35
+ *ENV_EPOCH_CACHE.get_or_init(|| {
36
+ std::env::var("EKKA_SECURITY_EPOCH")
37
+ .ok()
38
+ .and_then(|s| s.parse().ok())
39
+ })
40
+ }
41
+
42
+ /// Read epoch_seen from marker file
43
+ fn try_marker_epoch(home: &Path) -> Option<u32> {
44
+ let marker_path = home.join(".ekka-marker.json");
45
+ let content = std::fs::read_to_string(&marker_path).ok()?;
46
+ let marker: serde_json::Value = serde_json::from_str(&content).ok()?;
47
+ marker.get("epoch_seen").and_then(|v| v.as_u64()).map(|v| v as u32)
48
+ }
49
+
50
+ /// Resolve security epoch with precedence: env > marker > default
51
+ ///
52
+ /// Logs the resolution once per unique (home, source) combination.
53
+ pub fn resolve_security_epoch(home: &Path) -> u32 {
54
+ let (epoch, source) = resolve_with_source(home);
55
+
56
+ // Log resolution (only first time per call site in practice)
57
+ tracing::info!(
58
+ op = "security_epoch.resolved",
59
+ source = %source,
60
+ value = epoch,
61
+ "Security epoch resolved"
62
+ );
63
+
64
+ epoch
65
+ }
66
+
67
+ /// Resolve epoch and return both value and source
68
+ pub fn resolve_with_source(home: &Path) -> (u32, EpochSource) {
69
+ // 1. Check env var override
70
+ if let Some(epoch) = try_env_epoch() {
71
+ return (epoch, EpochSource::Env);
72
+ }
73
+
74
+ // 2. Read from marker file
75
+ if let Some(epoch) = try_marker_epoch(home) {
76
+ return (epoch, EpochSource::Marker);
77
+ }
78
+
79
+ // 3. Default
80
+ (1, EpochSource::Default)
81
+ }
82
+
83
+ #[cfg(test)]
84
+ mod tests {
85
+ use super::*;
86
+ use tempfile::TempDir;
87
+ use std::fs;
88
+
89
+ #[test]
90
+ fn test_marker_epoch_read() {
91
+ let temp = TempDir::new().unwrap();
92
+ let marker = serde_json::json!({
93
+ "schema_version": "1.0",
94
+ "app_name": "test",
95
+ "instance_id": "00000000-0000-0000-0000-000000000000",
96
+ "device_id_fingerprint": "sha256:test",
97
+ "created_at": "2024-01-01T00:00:00Z",
98
+ "last_seen_at": "2024-01-01T00:00:00Z",
99
+ "epoch_seen": 42,
100
+ "storage_layout_version": "v1"
101
+ });
102
+ fs::write(temp.path().join(".ekka-marker.json"), marker.to_string()).unwrap();
103
+
104
+ let epoch = try_marker_epoch(temp.path());
105
+ assert_eq!(epoch, Some(42));
106
+ }
107
+
108
+ #[test]
109
+ fn test_default_when_no_marker() {
110
+ let temp = TempDir::new().unwrap();
111
+ let (epoch, source) = resolve_with_source(temp.path());
112
+ assert_eq!(epoch, 1);
113
+ assert_eq!(source, EpochSource::Default);
114
+ }
115
+
116
+ #[test]
117
+ fn test_marker_source() {
118
+ let temp = TempDir::new().unwrap();
119
+ let marker = serde_json::json!({
120
+ "epoch_seen": 5
121
+ });
122
+ fs::write(temp.path().join(".ekka-marker.json"), marker.to_string()).unwrap();
123
+
124
+ let (epoch, source) = resolve_with_source(temp.path());
125
+ assert_eq!(epoch, 5);
126
+ assert_eq!(source, EpochSource::Marker);
127
+ }
128
+ }