create-ekka-desktop-app 0.2.2

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.
Files changed (96) hide show
  1. package/README.md +137 -0
  2. package/bin/cli.js +72 -0
  3. package/package.json +23 -0
  4. package/template/branding/app.json +6 -0
  5. package/template/branding/icon.icns +0 -0
  6. package/template/eslint.config.js +98 -0
  7. package/template/index.html +29 -0
  8. package/template/package.json +40 -0
  9. package/template/src/app/App.tsx +24 -0
  10. package/template/src/demo/DemoApp.tsx +260 -0
  11. package/template/src/demo/components/Banner.tsx +82 -0
  12. package/template/src/demo/components/EmptyState.tsx +61 -0
  13. package/template/src/demo/components/InfoPopover.tsx +171 -0
  14. package/template/src/demo/components/InfoTooltip.tsx +76 -0
  15. package/template/src/demo/components/LearnMore.tsx +98 -0
  16. package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
  17. package/template/src/demo/components/SetupWizard.tsx +48 -0
  18. package/template/src/demo/components/StatusBadge.tsx +83 -0
  19. package/template/src/demo/components/index.ts +10 -0
  20. package/template/src/demo/hooks/index.ts +6 -0
  21. package/template/src/demo/hooks/useAuditEvents.ts +30 -0
  22. package/template/src/demo/layout/Shell.tsx +110 -0
  23. package/template/src/demo/layout/Sidebar.tsx +192 -0
  24. package/template/src/demo/pages/AuditLogPage.tsx +235 -0
  25. package/template/src/demo/pages/DocGenPage.tsx +874 -0
  26. package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
  27. package/template/src/demo/pages/LoginPage.tsx +192 -0
  28. package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
  29. package/template/src/demo/pages/RunnerPage.tsx +445 -0
  30. package/template/src/demo/pages/SystemPage.tsx +557 -0
  31. package/template/src/demo/pages/VaultPage.tsx +805 -0
  32. package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
  33. package/template/src/ekka/audit/index.ts +7 -0
  34. package/template/src/ekka/audit/store.ts +68 -0
  35. package/template/src/ekka/audit/types.ts +22 -0
  36. package/template/src/ekka/auth/client.ts +212 -0
  37. package/template/src/ekka/auth/index.ts +30 -0
  38. package/template/src/ekka/auth/storage.ts +114 -0
  39. package/template/src/ekka/auth/types.ts +67 -0
  40. package/template/src/ekka/backend/demo.ts +151 -0
  41. package/template/src/ekka/backend/interface.ts +36 -0
  42. package/template/src/ekka/config.ts +48 -0
  43. package/template/src/ekka/constants.ts +143 -0
  44. package/template/src/ekka/errors.ts +54 -0
  45. package/template/src/ekka/index.ts +516 -0
  46. package/template/src/ekka/internal/backend.ts +156 -0
  47. package/template/src/ekka/internal/index.ts +7 -0
  48. package/template/src/ekka/ops/auth.ts +29 -0
  49. package/template/src/ekka/ops/debug.ts +68 -0
  50. package/template/src/ekka/ops/home.ts +101 -0
  51. package/template/src/ekka/ops/index.ts +16 -0
  52. package/template/src/ekka/ops/nodeCredentials.ts +131 -0
  53. package/template/src/ekka/ops/nodeSession.ts +145 -0
  54. package/template/src/ekka/ops/paths.ts +183 -0
  55. package/template/src/ekka/ops/runner.ts +86 -0
  56. package/template/src/ekka/ops/runtime.ts +31 -0
  57. package/template/src/ekka/ops/setup.ts +47 -0
  58. package/template/src/ekka/ops/vault.ts +459 -0
  59. package/template/src/ekka/ops/workflowRuns.ts +116 -0
  60. package/template/src/ekka/types.ts +82 -0
  61. package/template/src/ekka/utils/idempotency.ts +14 -0
  62. package/template/src/ekka/utils/index.ts +7 -0
  63. package/template/src/ekka/utils/time.ts +77 -0
  64. package/template/src/main.tsx +12 -0
  65. package/template/src/vite-env.d.ts +12 -0
  66. package/template/src-tauri/Cargo.toml +41 -0
  67. package/template/src-tauri/build.rs +3 -0
  68. package/template/src-tauri/capabilities/default.json +11 -0
  69. package/template/src-tauri/icons/icon.icns +0 -0
  70. package/template/src-tauri/icons/icon.png +0 -0
  71. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  72. package/template/src-tauri/src/bootstrap.rs +37 -0
  73. package/template/src-tauri/src/commands.rs +1215 -0
  74. package/template/src-tauri/src/device_secret.rs +111 -0
  75. package/template/src-tauri/src/engine_process.rs +538 -0
  76. package/template/src-tauri/src/grants.rs +129 -0
  77. package/template/src-tauri/src/handlers/home.rs +65 -0
  78. package/template/src-tauri/src/handlers/mod.rs +7 -0
  79. package/template/src-tauri/src/handlers/paths.rs +128 -0
  80. package/template/src-tauri/src/handlers/vault.rs +680 -0
  81. package/template/src-tauri/src/main.rs +243 -0
  82. package/template/src-tauri/src/node_auth.rs +858 -0
  83. package/template/src-tauri/src/node_credentials.rs +541 -0
  84. package/template/src-tauri/src/node_runner.rs +882 -0
  85. package/template/src-tauri/src/node_vault_crypto.rs +113 -0
  86. package/template/src-tauri/src/node_vault_store.rs +267 -0
  87. package/template/src-tauri/src/ops/auth.rs +50 -0
  88. package/template/src-tauri/src/ops/home.rs +251 -0
  89. package/template/src-tauri/src/ops/mod.rs +7 -0
  90. package/template/src-tauri/src/ops/runtime.rs +21 -0
  91. package/template/src-tauri/src/state.rs +639 -0
  92. package/template/src-tauri/src/types.rs +84 -0
  93. package/template/src-tauri/tauri.conf.json +41 -0
  94. package/template/tsconfig.json +26 -0
  95. package/template/tsconfig.tsbuildinfo +1 -0
  96. package/template/vite.config.ts +34 -0
@@ -0,0 +1,858 @@
1
+ //! Node Session Authentication
2
+ //!
3
+ //! Implements Ed25519-based node authentication for EKKA Desktop.
4
+ //!
5
+ //! ## Architecture
6
+ //!
7
+ //! - Node identity stored in `${EKKA_HOME}/node_identity.json` (public metadata only)
8
+ //! - Private key stored encrypted in vault at `node_keys/<node_id>/ed25519_private_key`
9
+ //! - Session tokens held in memory only (never persisted)
10
+ //!
11
+ //! ## Security Invariants
12
+ //!
13
+ //! - Private keys NEVER written to disk in plaintext
14
+ //! - Private keys NEVER logged
15
+ //! - Sign RAW NONCE BYTES only (no canonical JSON)
16
+ //! - Node identity = node_id + private key possession
17
+
18
+ #![allow(dead_code)] // API types may not all be used yet
19
+
20
+ use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
21
+ use chrono::{DateTime, Utc};
22
+ use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
23
+ use ekka_sdk_core::ekka_crypto::{decrypt, derive_key, encrypt, KeyDerivationConfig, KeyMaterial};
24
+ use rand::rngs::OsRng;
25
+ use serde::{Deserialize, Serialize};
26
+ use std::path::PathBuf;
27
+ use std::sync::RwLock;
28
+ use uuid::Uuid;
29
+ use zeroize::Zeroizing;
30
+
31
+ // =============================================================================
32
+ // Types
33
+ // =============================================================================
34
+
35
+ /// Node identity metadata (safe to store in plaintext)
36
+ #[derive(Debug, Clone, Serialize, Deserialize)]
37
+ pub struct NodeIdentity {
38
+ pub schema_version: String,
39
+ pub node_id: Uuid,
40
+ pub algorithm: String,
41
+ pub public_key_b64: String,
42
+ pub private_key_vault_ref: String,
43
+ pub created_at_iso_utc: String,
44
+ }
45
+
46
+ impl NodeIdentity {
47
+ pub fn new(node_id: Uuid, public_key: &VerifyingKey) -> Self {
48
+ let public_key_bytes = public_key.to_bytes();
49
+ Self {
50
+ schema_version: "node_identity.v1".to_string(),
51
+ node_id,
52
+ algorithm: "ed25519".to_string(),
53
+ public_key_b64: BASE64.encode(public_key_bytes),
54
+ private_key_vault_ref: format!("vault://node_keys/{}/ed25519_private_key", node_id),
55
+ created_at_iso_utc: Utc::now().to_rfc3339(),
56
+ }
57
+ }
58
+ }
59
+
60
+ /// Node session token (in-memory only)
61
+ #[derive(Debug, Clone)]
62
+ pub struct NodeSession {
63
+ pub token: String,
64
+ pub session_id: Uuid,
65
+ pub tenant_id: Uuid,
66
+ pub workspace_id: Uuid,
67
+ pub expires_at: DateTime<Utc>,
68
+ }
69
+
70
+ impl NodeSession {
71
+ /// Check if session is expired or about to expire (within 60 seconds)
72
+ pub fn is_expired(&self) -> bool {
73
+ Utc::now() + chrono::Duration::seconds(60) >= self.expires_at
74
+ }
75
+ }
76
+
77
+ /// Thread-safe session holder
78
+ pub struct NodeSessionHolder {
79
+ session: RwLock<Option<NodeSession>>,
80
+ }
81
+
82
+ impl NodeSessionHolder {
83
+ pub fn new() -> Self {
84
+ Self {
85
+ session: RwLock::new(None),
86
+ }
87
+ }
88
+
89
+ pub fn get(&self) -> Option<NodeSession> {
90
+ self.session.read().ok()?.clone()
91
+ }
92
+
93
+ pub fn set(&self, session: NodeSession) {
94
+ if let Ok(mut guard) = self.session.write() {
95
+ *guard = Some(session);
96
+ }
97
+ }
98
+
99
+ pub fn clear(&self) {
100
+ if let Ok(mut guard) = self.session.write() {
101
+ *guard = None;
102
+ }
103
+ }
104
+
105
+ /// Get valid session or None if expired
106
+ pub fn get_valid(&self) -> Option<NodeSession> {
107
+ let session = self.get()?;
108
+ if session.is_expired() {
109
+ None
110
+ } else {
111
+ Some(session)
112
+ }
113
+ }
114
+ }
115
+
116
+ impl Default for NodeSessionHolder {
117
+ fn default() -> Self {
118
+ Self::new()
119
+ }
120
+ }
121
+
122
+ // =============================================================================
123
+ // API Response Types
124
+ // =============================================================================
125
+
126
+ #[derive(Debug, Deserialize)]
127
+ pub struct RegisterNodeResponse {
128
+ pub node_id: String,
129
+ pub tenant_id: String,
130
+ pub status: String,
131
+ #[serde(default)]
132
+ pub registered_at: Option<String>,
133
+ }
134
+
135
+ #[derive(Debug, Deserialize)]
136
+ pub struct ChallengeResponse {
137
+ pub challenge_id: String,
138
+ pub nonce_b64: String,
139
+ pub expires_at_iso_utc: String,
140
+ }
141
+
142
+ #[derive(Debug, Deserialize)]
143
+ pub struct SessionResponse {
144
+ pub token: String,
145
+ pub session_id: String,
146
+ pub tenant_id: String,
147
+ pub workspace_id: String,
148
+ pub expires_at_iso_utc: String,
149
+ }
150
+
151
+ // =============================================================================
152
+ // Error Types
153
+ // =============================================================================
154
+
155
+ #[derive(Debug)]
156
+ pub enum NodeAuthError {
157
+ IoError(std::io::Error),
158
+ JsonError(serde_json::Error),
159
+ CryptoError(String),
160
+ HttpError(String),
161
+ VaultError(String),
162
+ IdentityMismatch(String),
163
+ SessionExpired,
164
+ NotRegistered,
165
+ }
166
+
167
+ impl std::fmt::Display for NodeAuthError {
168
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169
+ match self {
170
+ NodeAuthError::IoError(e) => write!(f, "I/O error: {}", e),
171
+ NodeAuthError::JsonError(e) => write!(f, "JSON error: {}", e),
172
+ NodeAuthError::CryptoError(e) => write!(f, "Crypto error: {}", e),
173
+ NodeAuthError::HttpError(e) => write!(f, "HTTP error: {}", e),
174
+ NodeAuthError::VaultError(e) => write!(f, "Vault error: {}", e),
175
+ NodeAuthError::IdentityMismatch(e) => write!(f, "Identity mismatch: {}", e),
176
+ NodeAuthError::SessionExpired => write!(f, "Session expired"),
177
+ NodeAuthError::NotRegistered => write!(f, "Node not registered"),
178
+ }
179
+ }
180
+ }
181
+
182
+ impl std::error::Error for NodeAuthError {}
183
+
184
+ impl From<std::io::Error> for NodeAuthError {
185
+ fn from(e: std::io::Error) -> Self {
186
+ NodeAuthError::IoError(e)
187
+ }
188
+ }
189
+
190
+ impl From<serde_json::Error> for NodeAuthError {
191
+ fn from(e: serde_json::Error) -> Self {
192
+ NodeAuthError::JsonError(e)
193
+ }
194
+ }
195
+
196
+ // =============================================================================
197
+ // Core Functions
198
+ // =============================================================================
199
+
200
+ /// Load node identity from file, or return None if not found
201
+ pub fn load_node_identity(home_path: &PathBuf) -> Result<Option<NodeIdentity>, NodeAuthError> {
202
+ let identity_path = home_path.join("node_identity.json");
203
+ if !identity_path.exists() {
204
+ return Ok(None);
205
+ }
206
+
207
+ let content = std::fs::read_to_string(&identity_path)?;
208
+ let identity: NodeIdentity = serde_json::from_str(&content)?;
209
+ Ok(Some(identity))
210
+ }
211
+
212
+ /// Save node identity to file
213
+ pub fn save_node_identity(
214
+ home_path: &PathBuf,
215
+ identity: &NodeIdentity,
216
+ ) -> Result<(), NodeAuthError> {
217
+ let identity_path = home_path.join("node_identity.json");
218
+ let content = serde_json::to_string_pretty(identity)?;
219
+ std::fs::write(&identity_path, content)?;
220
+ Ok(())
221
+ }
222
+
223
+ /// Generate a new Ed25519 keypair
224
+ ///
225
+ /// Returns (signing_key, verifying_key) where signing_key contains the private key
226
+ pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
227
+ let signing_key = SigningKey::generate(&mut OsRng);
228
+ let verifying_key = signing_key.verifying_key();
229
+ (signing_key, verifying_key)
230
+ }
231
+
232
+ /// Get the vault path for the private key
233
+ pub fn get_private_key_vault_path(node_id: &Uuid) -> PathBuf {
234
+ PathBuf::from(format!("node_keys/{}/ed25519_private_key.enc", node_id))
235
+ }
236
+
237
+ /// Derive encryption key for node-level secrets
238
+ ///
239
+ /// Uses node_id as context, device_id for binding
240
+ fn derive_node_encryption_key(node_id: &Uuid, device_fingerprint: Option<&str>) -> KeyMaterial {
241
+ // Use node_id + device fingerprint for key derivation
242
+ // This binds the key to this specific node on this device
243
+ let device_secret = device_fingerprint.unwrap_or("ekka-desktop-default-device");
244
+
245
+ derive_key(
246
+ device_secret,
247
+ &node_id.to_string(),
248
+ 1, // security_epoch for node keys
249
+ "node_private_key",
250
+ &KeyDerivationConfig::default(),
251
+ )
252
+ }
253
+
254
+ /// Store private key encrypted at rest
255
+ ///
256
+ /// The private key is stored as encrypted base64-encoded 64-byte keypair
257
+ /// Uses node-level encryption (not tenant-scoped)
258
+ pub fn store_private_key_encrypted(
259
+ home_path: &PathBuf,
260
+ node_id: &Uuid,
261
+ signing_key: &SigningKey,
262
+ device_fingerprint: Option<&str>,
263
+ ) -> Result<(), NodeAuthError> {
264
+ let vault_path = home_path.join("vault").join(get_private_key_vault_path(node_id));
265
+
266
+ // Ensure parent directory exists
267
+ if let Some(parent) = vault_path.parent() {
268
+ std::fs::create_dir_all(parent)?;
269
+ }
270
+
271
+ // Ed25519 private key is 64 bytes (32-byte seed + 32-byte public)
272
+ let key_bytes = signing_key.to_keypair_bytes();
273
+ let encoded = BASE64.encode(&key_bytes);
274
+
275
+ // Derive encryption key for this node
276
+ let encryption_key = derive_node_encryption_key(node_id, device_fingerprint);
277
+
278
+ // Encrypt the key
279
+ let encrypted = encrypt(encoded.as_bytes(), &encryption_key)
280
+ .map_err(|e| NodeAuthError::CryptoError(format!("Encryption failed: {}", e)))?;
281
+
282
+ // Write encrypted data
283
+ std::fs::write(&vault_path, encrypted)?;
284
+
285
+ tracing::info!(
286
+ op = "node_auth.store_private_key",
287
+ node_id = %node_id,
288
+ vault_path = %vault_path.display(),
289
+ "Private key stored encrypted"
290
+ );
291
+
292
+ Ok(())
293
+ }
294
+
295
+ /// Check if private key exists in vault
296
+ pub fn private_key_exists(home_path: &PathBuf, node_id: &Uuid) -> bool {
297
+ let vault_path = home_path.join("vault").join(get_private_key_vault_path(node_id));
298
+ vault_path.exists()
299
+ }
300
+
301
+ /// Load private key from encrypted storage
302
+ ///
303
+ /// Returns the signing key or error if not found/corrupted
304
+ pub fn load_private_key_encrypted(
305
+ home_path: &PathBuf,
306
+ node_id: &Uuid,
307
+ device_fingerprint: Option<&str>,
308
+ ) -> Result<SigningKey, NodeAuthError> {
309
+ let vault_path = home_path.join("vault").join(get_private_key_vault_path(node_id));
310
+
311
+ if !vault_path.exists() {
312
+ return Err(NodeAuthError::VaultError(format!(
313
+ "Private key not found at {}",
314
+ vault_path.display()
315
+ )));
316
+ }
317
+
318
+ // Read encrypted data
319
+ let encrypted = std::fs::read(&vault_path)?;
320
+
321
+ // Derive encryption key
322
+ let encryption_key = derive_node_encryption_key(node_id, device_fingerprint);
323
+
324
+ // Decrypt
325
+ let decrypted = decrypt(&encrypted, &encryption_key)
326
+ .map_err(|e| NodeAuthError::CryptoError(format!("Decryption failed: {}", e)))?;
327
+
328
+ // Decode base64
329
+ let encoded = String::from_utf8(decrypted)
330
+ .map_err(|e| NodeAuthError::CryptoError(format!("Invalid UTF-8: {}", e)))?;
331
+
332
+ // Wrap in Zeroizing to clear memory after use
333
+ let key_bytes_vec: Zeroizing<Vec<u8>> = Zeroizing::new(
334
+ BASE64
335
+ .decode(&encoded)
336
+ .map_err(|e| NodeAuthError::CryptoError(format!("Invalid base64: {}", e)))?,
337
+ );
338
+
339
+ if key_bytes_vec.len() != 64 {
340
+ return Err(NodeAuthError::CryptoError(format!(
341
+ "Invalid key length: expected 64, got {}",
342
+ key_bytes_vec.len()
343
+ )));
344
+ }
345
+
346
+ let mut key_bytes: [u8; 64] = [0u8; 64];
347
+ key_bytes.copy_from_slice(&key_bytes_vec);
348
+
349
+ let signing_key = SigningKey::from_keypair_bytes(&key_bytes)
350
+ .map_err(|e| NodeAuthError::CryptoError(format!("Invalid keypair bytes: {}", e)))?;
351
+
352
+ // Zero out the array
353
+ key_bytes.iter_mut().for_each(|b| *b = 0);
354
+
355
+ Ok(signing_key)
356
+ }
357
+
358
+ /// Sign raw nonce bytes with Ed25519
359
+ ///
360
+ /// IMPORTANT: Signs RAW BYTES, not JSON or any other encoding
361
+ pub fn sign_nonce(signing_key: &SigningKey, nonce_bytes: &[u8]) -> Signature {
362
+ signing_key.sign(nonce_bytes)
363
+ }
364
+
365
+ // =============================================================================
366
+ // HTTP API Calls
367
+ // =============================================================================
368
+
369
+ /// Register node with engine (idempotent)
370
+ pub fn register_node(
371
+ engine_url: &str,
372
+ user_jwt: &str,
373
+ node_id: &Uuid,
374
+ public_key_b64: &str,
375
+ default_workspace_id: &str,
376
+ device_id_fingerprint: Option<&str>,
377
+ ) -> Result<RegisterNodeResponse, NodeAuthError> {
378
+ let client = reqwest::blocking::Client::builder()
379
+ .timeout(std::time::Duration::from_secs(30))
380
+ .build()
381
+ .map_err(|e| NodeAuthError::HttpError(format!("Client build error: {}", e)))?;
382
+
383
+ let request_id = Uuid::new_v4().to_string();
384
+
385
+ let mut body = serde_json::json!({
386
+ "node_id": node_id.to_string(),
387
+ "public_key_b64": public_key_b64,
388
+ "default_workspace_id": default_workspace_id,
389
+ "display_name": "ekka-desktop",
390
+ "node_type": "desktop"
391
+ });
392
+
393
+ if let Some(fingerprint) = device_id_fingerprint {
394
+ body["device_id_fingerprint"] = serde_json::json!(fingerprint);
395
+ }
396
+
397
+ let response = client
398
+ .post(format!("{}/engine/nodes/register", engine_url))
399
+ .header("Authorization", format!("Bearer {}", user_jwt))
400
+ .header("Content-Type", "application/json")
401
+ .header("X-EKKA-PROOF-TYPE", "jwt")
402
+ .header("X-REQUEST-ID", &request_id)
403
+ .header("X-EKKA-CORRELATION-ID", &request_id)
404
+ .header("X-EKKA-MODULE", "desktop.node_auth")
405
+ .header("X-EKKA-ACTION", "register")
406
+ .header("X-EKKA-CLIENT", "ekka-desktop")
407
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
408
+ .json(&body)
409
+ .send()
410
+ .map_err(|e| NodeAuthError::HttpError(format!("Request failed: {}", e)))?;
411
+
412
+ let status = response.status();
413
+
414
+ // 201 = created, 409 = already exists (both are OK)
415
+ if status.as_u16() == 201 || status.as_u16() == 409 {
416
+ // For 409, engine returns { error: "node_exists", message: "..." }
417
+ // We construct a synthetic response
418
+ if status.as_u16() == 409 {
419
+ tracing::info!(
420
+ op = "node_auth.register.exists",
421
+ node_id = %node_id,
422
+ "Node already registered (409), continuing to challenge"
423
+ );
424
+ return Ok(RegisterNodeResponse {
425
+ node_id: node_id.to_string(),
426
+ tenant_id: String::new(), // Will be filled from session
427
+ status: "active".to_string(),
428
+ registered_at: None,
429
+ });
430
+ }
431
+
432
+ let result: RegisterNodeResponse = response
433
+ .json()
434
+ .map_err(|e| NodeAuthError::HttpError(format!("Parse error: {}", e)))?;
435
+ return Ok(result);
436
+ }
437
+
438
+ let body = response.text().unwrap_or_default();
439
+ Err(NodeAuthError::HttpError(format!(
440
+ "Register failed ({}): {}",
441
+ status, body
442
+ )))
443
+ }
444
+
445
+ /// Get challenge for node authentication
446
+ pub fn get_challenge(engine_url: &str, node_id: &Uuid) -> Result<ChallengeResponse, NodeAuthError> {
447
+ let client = reqwest::blocking::Client::builder()
448
+ .timeout(std::time::Duration::from_secs(30))
449
+ .build()
450
+ .map_err(|e| NodeAuthError::HttpError(format!("Client build error: {}", e)))?;
451
+
452
+ let request_id = Uuid::new_v4().to_string();
453
+
454
+ let response = client
455
+ .post(format!("{}/engine/nodes/challenge", engine_url))
456
+ .header("Content-Type", "application/json")
457
+ .header("X-EKKA-PROOF-TYPE", "anonymous")
458
+ .header("X-REQUEST-ID", &request_id)
459
+ .header("X-EKKA-CORRELATION-ID", &request_id)
460
+ .header("X-EKKA-MODULE", "desktop.node_auth")
461
+ .header("X-EKKA-ACTION", "challenge")
462
+ .header("X-EKKA-CLIENT", "ekka-desktop")
463
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
464
+ .json(&serde_json::json!({ "node_id": node_id.to_string() }))
465
+ .send()
466
+ .map_err(|e| NodeAuthError::HttpError(format!("Request failed: {}", e)))?;
467
+
468
+ if !response.status().is_success() {
469
+ let status = response.status();
470
+ let body = response.text().unwrap_or_default();
471
+ return Err(NodeAuthError::HttpError(format!(
472
+ "Challenge failed ({}): {}",
473
+ status, body
474
+ )));
475
+ }
476
+
477
+ let result: ChallengeResponse = response
478
+ .json()
479
+ .map_err(|e| NodeAuthError::HttpError(format!("Parse error: {}", e)))?;
480
+
481
+ Ok(result)
482
+ }
483
+
484
+ /// Create session by signing challenge
485
+ pub fn create_session(
486
+ engine_url: &str,
487
+ node_id: &Uuid,
488
+ challenge_id: &str,
489
+ signature_b64: &str,
490
+ ) -> Result<SessionResponse, NodeAuthError> {
491
+ let client = reqwest::blocking::Client::builder()
492
+ .timeout(std::time::Duration::from_secs(30))
493
+ .build()
494
+ .map_err(|e| NodeAuthError::HttpError(format!("Client build error: {}", e)))?;
495
+
496
+ let request_id = Uuid::new_v4().to_string();
497
+
498
+ let response = client
499
+ .post(format!("{}/engine/nodes/session", engine_url))
500
+ .header("Content-Type", "application/json")
501
+ .header("X-EKKA-PROOF-TYPE", "ed25519")
502
+ .header("X-REQUEST-ID", &request_id)
503
+ .header("X-EKKA-CORRELATION-ID", &request_id)
504
+ .header("X-EKKA-MODULE", "desktop.node_auth")
505
+ .header("X-EKKA-ACTION", "session")
506
+ .header("X-EKKA-CLIENT", "ekka-desktop")
507
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
508
+ .json(&serde_json::json!({
509
+ "node_id": node_id.to_string(),
510
+ "challenge_id": challenge_id,
511
+ "signature_b64": signature_b64
512
+ }))
513
+ .send()
514
+ .map_err(|e| NodeAuthError::HttpError(format!("Request failed: {}", e)))?;
515
+
516
+ if !response.status().is_success() {
517
+ let status = response.status();
518
+ let body = response.text().unwrap_or_default();
519
+ return Err(NodeAuthError::HttpError(format!(
520
+ "Session failed ({}): {}",
521
+ status, body
522
+ )));
523
+ }
524
+
525
+ let result: SessionResponse = response
526
+ .json()
527
+ .map_err(|e| NodeAuthError::HttpError(format!("Parse error: {}", e)))?;
528
+
529
+ Ok(result)
530
+ }
531
+
532
+ // =============================================================================
533
+ // Orchestration
534
+ // =============================================================================
535
+
536
+ /// Bootstrap result containing identity and optional session
537
+ pub struct BootstrapResult {
538
+ pub identity: NodeIdentity,
539
+ pub session: Option<NodeSession>,
540
+ pub registered: bool,
541
+ }
542
+
543
+ /// Ensure node identity exists (load or create)
544
+ ///
545
+ /// This is called during engine_connect to set up node identity.
546
+ /// Does NOT require authentication - just ensures keypair exists.
547
+ pub fn ensure_node_identity(
548
+ home_path: &PathBuf,
549
+ node_id: Uuid,
550
+ device_fingerprint: Option<&str>,
551
+ ) -> Result<NodeIdentity, NodeAuthError> {
552
+ // Try to load existing identity
553
+ if let Some(existing) = load_node_identity(home_path)? {
554
+ // Verify node_id matches
555
+ if existing.node_id != node_id {
556
+ return Err(NodeAuthError::IdentityMismatch(format!(
557
+ "node_identity.json has node_id {} but marker has {}",
558
+ existing.node_id, node_id
559
+ )));
560
+ }
561
+
562
+ // Verify private key exists
563
+ if !private_key_exists(home_path, &node_id) {
564
+ // Private key missing - regenerate
565
+ tracing::warn!(
566
+ op = "node_auth.key_missing",
567
+ node_id = %node_id,
568
+ "Private key missing from vault, regenerating"
569
+ );
570
+
571
+ let (signing_key, verifying_key) = generate_keypair();
572
+ store_private_key_encrypted(home_path, &node_id, &signing_key, device_fingerprint)?;
573
+
574
+ // Update identity with new public key
575
+ let identity = NodeIdentity::new(node_id, &verifying_key);
576
+ save_node_identity(home_path, &identity)?;
577
+
578
+ return Ok(identity);
579
+ }
580
+
581
+ return Ok(existing);
582
+ }
583
+
584
+ // Create new identity
585
+ tracing::info!(
586
+ op = "node_auth.create_identity",
587
+ node_id = %node_id,
588
+ "Creating new node identity"
589
+ );
590
+
591
+ let (signing_key, verifying_key) = generate_keypair();
592
+ store_private_key_encrypted(home_path, &node_id, &signing_key, device_fingerprint)?;
593
+
594
+ let identity = NodeIdentity::new(node_id, &verifying_key);
595
+ save_node_identity(home_path, &identity)?;
596
+
597
+ Ok(identity)
598
+ }
599
+
600
+ /// Bootstrap full node session
601
+ ///
602
+ /// 1. Ensure identity exists
603
+ /// 2. Register node (idempotent)
604
+ /// 3. Get challenge
605
+ /// 4. Sign nonce
606
+ /// 5. Create session
607
+ pub fn bootstrap_node_session(
608
+ home_path: &PathBuf,
609
+ node_id: Uuid,
610
+ engine_url: &str,
611
+ user_jwt: &str,
612
+ default_workspace_id: &str,
613
+ device_fingerprint: Option<&str>,
614
+ ) -> Result<BootstrapResult, NodeAuthError> {
615
+ // Step 1: Ensure identity
616
+ let identity = ensure_node_identity(home_path, node_id, device_fingerprint)?;
617
+
618
+ // Step 2: Register node (idempotent)
619
+ tracing::info!(
620
+ op = "node_auth.register",
621
+ node_id = %node_id,
622
+ workspace_id = %default_workspace_id,
623
+ "Registering node with engine"
624
+ );
625
+
626
+ let register_result = register_node(
627
+ engine_url,
628
+ user_jwt,
629
+ &node_id,
630
+ &identity.public_key_b64,
631
+ default_workspace_id,
632
+ device_fingerprint,
633
+ )?;
634
+
635
+ // Registration succeeded (HTTP 201) - status should be "active"
636
+ tracing::info!(
637
+ op = "node_auth.register.ok",
638
+ node_id = %register_result.node_id,
639
+ status = %register_result.status,
640
+ "Node registered successfully"
641
+ );
642
+
643
+ // Step 3: Get challenge
644
+ tracing::info!(
645
+ op = "node_auth.challenge",
646
+ node_id = %node_id,
647
+ "Getting challenge from engine"
648
+ );
649
+
650
+ let challenge = get_challenge(engine_url, &node_id)?;
651
+
652
+ // Step 4: Load private key and sign nonce
653
+ let signing_key = load_private_key_encrypted(home_path, &node_id, device_fingerprint)?;
654
+
655
+ let nonce_bytes = BASE64
656
+ .decode(&challenge.nonce_b64)
657
+ .map_err(|e| NodeAuthError::CryptoError(format!("Invalid nonce base64: {}", e)))?;
658
+
659
+ // Sign RAW nonce bytes
660
+ let signature = sign_nonce(&signing_key, &nonce_bytes);
661
+ let signature_b64 = BASE64.encode(signature.to_bytes());
662
+
663
+ // Drop signing key from memory
664
+ drop(signing_key);
665
+
666
+ // Step 5: Create session
667
+ tracing::info!(
668
+ op = "node_auth.session",
669
+ node_id = %node_id,
670
+ challenge_id = %challenge.challenge_id,
671
+ "Creating node session"
672
+ );
673
+
674
+ let session_response = create_session(
675
+ engine_url,
676
+ &node_id,
677
+ &challenge.challenge_id,
678
+ &signature_b64,
679
+ )?;
680
+
681
+ let session = NodeSession {
682
+ token: session_response.token,
683
+ session_id: Uuid::parse_str(&session_response.session_id)
684
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid session_id: {}", e)))?,
685
+ tenant_id: Uuid::parse_str(&session_response.tenant_id)
686
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid tenant_id: {}", e)))?,
687
+ workspace_id: Uuid::parse_str(&session_response.workspace_id)
688
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid workspace_id: {}", e)))?,
689
+ expires_at: DateTime::parse_from_rfc3339(&session_response.expires_at_iso_utc)
690
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid expires_at: {}", e)))?
691
+ .with_timezone(&Utc),
692
+ };
693
+
694
+ tracing::info!(
695
+ op = "node_auth.session_created",
696
+ node_id = %node_id,
697
+ session_id = %session.session_id,
698
+ tenant_id = %session.tenant_id,
699
+ workspace_id = %session.workspace_id,
700
+ "Node session established"
701
+ );
702
+
703
+ Ok(BootstrapResult {
704
+ identity,
705
+ session: Some(session),
706
+ registered: true,
707
+ })
708
+ }
709
+
710
+ /// Refresh node session (challenge/sign/session only)
711
+ pub fn refresh_node_session(
712
+ home_path: &PathBuf,
713
+ node_id: &Uuid,
714
+ engine_url: &str,
715
+ device_fingerprint: Option<&str>,
716
+ ) -> Result<NodeSession, NodeAuthError> {
717
+ // Get challenge
718
+ let challenge = get_challenge(engine_url, node_id)?;
719
+
720
+ // Load private key and sign
721
+ let signing_key = load_private_key_encrypted(home_path, node_id, device_fingerprint)?;
722
+
723
+ let nonce_bytes = BASE64
724
+ .decode(&challenge.nonce_b64)
725
+ .map_err(|e| NodeAuthError::CryptoError(format!("Invalid nonce base64: {}", e)))?;
726
+
727
+ let signature = sign_nonce(&signing_key, &nonce_bytes);
728
+ let signature_b64 = BASE64.encode(signature.to_bytes());
729
+
730
+ drop(signing_key);
731
+
732
+ // Create session
733
+ let session_response = create_session(
734
+ engine_url,
735
+ node_id,
736
+ &challenge.challenge_id,
737
+ &signature_b64,
738
+ )?;
739
+
740
+ let session = NodeSession {
741
+ token: session_response.token,
742
+ session_id: Uuid::parse_str(&session_response.session_id)
743
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid session_id: {}", e)))?,
744
+ tenant_id: Uuid::parse_str(&session_response.tenant_id)
745
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid tenant_id: {}", e)))?,
746
+ workspace_id: Uuid::parse_str(&session_response.workspace_id)
747
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid workspace_id: {}", e)))?,
748
+ expires_at: DateTime::parse_from_rfc3339(&session_response.expires_at_iso_utc)
749
+ .map_err(|e| NodeAuthError::HttpError(format!("Invalid expires_at: {}", e)))?
750
+ .with_timezone(&Utc),
751
+ };
752
+
753
+ Ok(session)
754
+ }
755
+
756
+ // =============================================================================
757
+ // Runner Integration
758
+ // =============================================================================
759
+
760
+ /// Runner configuration using node session (NOT internal service key)
761
+ #[derive(Debug, Clone)]
762
+ pub struct NodeSessionRunnerConfig {
763
+ pub engine_url: String,
764
+ pub node_url: String,
765
+ pub session_token: String,
766
+ pub tenant_id: Uuid,
767
+ pub workspace_id: Uuid,
768
+ pub node_id: Uuid,
769
+ }
770
+
771
+ impl NodeSessionRunnerConfig {
772
+ pub fn from_session(session: &NodeSession, node_id: Uuid) -> Result<Self, String> {
773
+ let engine_url = std::env::var("ENGINE_URL")
774
+ .or_else(|_| std::env::var("EKKA_ENGINE_URL"))
775
+ .map_err(|_| "ENGINE_URL or EKKA_ENGINE_URL required")?;
776
+
777
+ let node_url = std::env::var("NODE_URL").unwrap_or_else(|_| "http://127.0.0.1:7777".to_string());
778
+
779
+ Ok(Self {
780
+ engine_url,
781
+ node_url,
782
+ session_token: session.token.clone(),
783
+ tenant_id: session.tenant_id,
784
+ workspace_id: session.workspace_id,
785
+ node_id,
786
+ })
787
+ }
788
+
789
+ /// Get security headers for runner HTTP calls
790
+ ///
791
+ /// Uses node_session proof type instead of internal
792
+ pub fn security_headers(&self) -> Vec<(&'static str, String)> {
793
+ vec![
794
+ ("Authorization", format!("Bearer {}", self.session_token)),
795
+ ("X-EKKA-PROOF-TYPE", "node_session".to_string()),
796
+ ("X-REQUEST-ID", Uuid::new_v4().to_string()),
797
+ ("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string()),
798
+ ("X-EKKA-MODULE", "engine.runner_tasks".to_string()),
799
+ ("X-EKKA-CLIENT", "ekka-desktop".to_string()),
800
+ ("X-EKKA-CLIENT-VERSION", "0.2.0".to_string()),
801
+ ("X-EKKA-NODE-ID", self.node_id.to_string()),
802
+ ]
803
+ }
804
+ }
805
+
806
+ #[cfg(test)]
807
+ mod tests {
808
+ use super::*;
809
+
810
+ #[test]
811
+ fn test_keypair_generation() {
812
+ let (signing_key, verifying_key) = generate_keypair();
813
+
814
+ // Verify signature works
815
+ let message = b"test message";
816
+ let signature = signing_key.sign(message);
817
+
818
+ assert!(verifying_key.verify_strict(message, &signature).is_ok());
819
+ }
820
+
821
+ #[test]
822
+ fn test_node_identity_serialization() {
823
+ let node_id = Uuid::new_v4();
824
+ let (_, verifying_key) = generate_keypair();
825
+
826
+ let identity = NodeIdentity::new(node_id, &verifying_key);
827
+
828
+ let json = serde_json::to_string_pretty(&identity).unwrap();
829
+ let parsed: NodeIdentity = serde_json::from_str(&json).unwrap();
830
+
831
+ assert_eq!(parsed.node_id, node_id);
832
+ assert_eq!(parsed.algorithm, "ed25519");
833
+ assert_eq!(parsed.schema_version, "node_identity.v1");
834
+ }
835
+
836
+ #[test]
837
+ fn test_session_expiry() {
838
+ let session = NodeSession {
839
+ token: "test".to_string(),
840
+ session_id: Uuid::new_v4(),
841
+ tenant_id: Uuid::new_v4(),
842
+ workspace_id: Uuid::new_v4(),
843
+ expires_at: Utc::now() + chrono::Duration::hours(1),
844
+ };
845
+
846
+ assert!(!session.is_expired());
847
+
848
+ let expired_session = NodeSession {
849
+ token: "test".to_string(),
850
+ session_id: Uuid::new_v4(),
851
+ tenant_id: Uuid::new_v4(),
852
+ workspace_id: Uuid::new_v4(),
853
+ expires_at: Utc::now() - chrono::Duration::hours(1),
854
+ };
855
+
856
+ assert!(expired_session.is_expired());
857
+ }
858
+ }