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.
- package/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|