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,113 @@
|
|
|
1
|
+
//! Node Vault Cryptography
|
|
2
|
+
//!
|
|
3
|
+
//! Provides key derivation and AES-256-GCM encryption for node-level secrets.
|
|
4
|
+
//! Uses the existing ekka-crypto primitives for consistency.
|
|
5
|
+
|
|
6
|
+
use crate::device_secret::load_or_create_device_secret;
|
|
7
|
+
use ekka_sdk_core::ekka_crypto::{self, KeyDerivationConfig, KeyMaterial};
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
|
|
10
|
+
/// Purpose label for node vault key derivation
|
|
11
|
+
const NODE_VAULT_PURPOSE: &str = "node-vault";
|
|
12
|
+
|
|
13
|
+
/// Derive the encryption key for the node vault
|
|
14
|
+
///
|
|
15
|
+
/// Key derivation inputs:
|
|
16
|
+
/// - device_secret: 32 bytes from device secret file (device-bound)
|
|
17
|
+
/// - security_epoch: Current security epoch (for key rotation)
|
|
18
|
+
///
|
|
19
|
+
/// Note: node_id is NOT used in key derivation because it's a business
|
|
20
|
+
/// identifier stored inside the encrypted credentials, not a cryptographic input.
|
|
21
|
+
///
|
|
22
|
+
/// Uses PBKDF2-SHA256 with 100k iterations (via ekka-crypto).
|
|
23
|
+
pub fn derive_node_vault_key(home: &Path, epoch: u32) -> anyhow::Result<KeyMaterial> {
|
|
24
|
+
let device_secret = load_or_create_device_secret(home)?;
|
|
25
|
+
|
|
26
|
+
// Convert device secret to hex string for derivation
|
|
27
|
+
let device_secret_hex = hex::encode(device_secret);
|
|
28
|
+
|
|
29
|
+
// User context is fixed for node vault (no user-specific data needed)
|
|
30
|
+
let user_context = "node-vault-context";
|
|
31
|
+
|
|
32
|
+
let config = KeyDerivationConfig::default();
|
|
33
|
+
|
|
34
|
+
let key = ekka_crypto::derive_key(
|
|
35
|
+
&device_secret_hex,
|
|
36
|
+
user_context,
|
|
37
|
+
epoch,
|
|
38
|
+
NODE_VAULT_PURPOSE,
|
|
39
|
+
&config,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
Ok(key)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Encrypt plaintext bytes using AES-256-GCM
|
|
46
|
+
///
|
|
47
|
+
/// Returns the encrypted envelope as bytes (version || nonce || ciphertext).
|
|
48
|
+
pub fn encrypt_node_value(key: &KeyMaterial, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|
49
|
+
ekka_crypto::encrypt(plaintext, key).map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Decrypt ciphertext bytes using AES-256-GCM
|
|
53
|
+
///
|
|
54
|
+
/// Expects the encrypted envelope format (version || nonce || ciphertext).
|
|
55
|
+
pub fn decrypt_node_value(key: &KeyMaterial, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|
56
|
+
ekka_crypto::decrypt(ciphertext, key).map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[cfg(test)]
|
|
60
|
+
mod tests {
|
|
61
|
+
use super::*;
|
|
62
|
+
use tempfile::TempDir;
|
|
63
|
+
|
|
64
|
+
#[test]
|
|
65
|
+
fn test_derive_node_vault_key_deterministic() {
|
|
66
|
+
let temp_dir = TempDir::new().unwrap();
|
|
67
|
+
let epoch = 1u32;
|
|
68
|
+
|
|
69
|
+
let key1 = derive_node_vault_key(temp_dir.path(), epoch).unwrap();
|
|
70
|
+
let key2 = derive_node_vault_key(temp_dir.path(), epoch).unwrap();
|
|
71
|
+
|
|
72
|
+
// Same inputs should produce same key
|
|
73
|
+
assert_eq!(key1.as_bytes(), key2.as_bytes());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn test_derive_node_vault_key_different_epoch() {
|
|
78
|
+
let temp_dir = TempDir::new().unwrap();
|
|
79
|
+
|
|
80
|
+
let key1 = derive_node_vault_key(temp_dir.path(), 1).unwrap();
|
|
81
|
+
let key2 = derive_node_vault_key(temp_dir.path(), 2).unwrap();
|
|
82
|
+
|
|
83
|
+
// Different epochs should produce different keys
|
|
84
|
+
assert_ne!(key1.as_bytes(), key2.as_bytes());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[test]
|
|
88
|
+
fn test_encrypt_decrypt_roundtrip() {
|
|
89
|
+
let temp_dir = TempDir::new().unwrap();
|
|
90
|
+
let key = derive_node_vault_key(temp_dir.path(), 1).unwrap();
|
|
91
|
+
|
|
92
|
+
let plaintext = b"test secret data";
|
|
93
|
+
let ciphertext = encrypt_node_value(&key, plaintext).unwrap();
|
|
94
|
+
let decrypted = decrypt_node_value(&key, &ciphertext).unwrap();
|
|
95
|
+
|
|
96
|
+
assert_eq!(plaintext.as_slice(), decrypted.as_slice());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn test_decrypt_wrong_key_fails() {
|
|
101
|
+
let temp_dir = TempDir::new().unwrap();
|
|
102
|
+
|
|
103
|
+
let key1 = derive_node_vault_key(temp_dir.path(), 1).unwrap();
|
|
104
|
+
let key2 = derive_node_vault_key(temp_dir.path(), 2).unwrap();
|
|
105
|
+
|
|
106
|
+
let plaintext = b"secret";
|
|
107
|
+
let ciphertext = encrypt_node_value(&key1, plaintext).unwrap();
|
|
108
|
+
|
|
109
|
+
// Decrypting with wrong key should fail
|
|
110
|
+
let result = decrypt_node_value(&key2, &ciphertext);
|
|
111
|
+
assert!(result.is_err());
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
//! Node Vault Store
|
|
2
|
+
//!
|
|
3
|
+
//! Encrypted storage for node-level secrets that works before user authentication.
|
|
4
|
+
//! Uses AES-256-GCM with device-bound key derivation.
|
|
5
|
+
//!
|
|
6
|
+
//! Storage layout:
|
|
7
|
+
//! ```text
|
|
8
|
+
//! {EKKA_HOME}/vault/node/
|
|
9
|
+
//! values/
|
|
10
|
+
//! node_credentials.enc # Encrypted node credentials
|
|
11
|
+
//! ```
|
|
12
|
+
|
|
13
|
+
use crate::node_vault_crypto::{decrypt_node_value, derive_node_vault_key, encrypt_node_value};
|
|
14
|
+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
15
|
+
use serde::{Deserialize, Serialize};
|
|
16
|
+
use std::fs;
|
|
17
|
+
use std::io::Write;
|
|
18
|
+
use std::path::{Path, PathBuf};
|
|
19
|
+
|
|
20
|
+
#[cfg(unix)]
|
|
21
|
+
use std::os::unix::fs::PermissionsExt;
|
|
22
|
+
|
|
23
|
+
/// Secret ID for node credentials
|
|
24
|
+
pub const SECRET_ID_NODE_CREDENTIALS: &str = "node_credentials";
|
|
25
|
+
|
|
26
|
+
/// Encrypted value envelope stored on disk
|
|
27
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
28
|
+
struct EncryptedEnvelope {
|
|
29
|
+
/// Version for future format changes
|
|
30
|
+
v: u8,
|
|
31
|
+
/// Base64-encoded encrypted data (version || nonce || ciphertext)
|
|
32
|
+
data_b64: String,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl EncryptedEnvelope {
|
|
36
|
+
const CURRENT_VERSION: u8 = 1;
|
|
37
|
+
|
|
38
|
+
fn new(encrypted_bytes: &[u8]) -> Self {
|
|
39
|
+
Self {
|
|
40
|
+
v: Self::CURRENT_VERSION,
|
|
41
|
+
data_b64: BASE64.encode(encrypted_bytes),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn decode(&self) -> anyhow::Result<Vec<u8>> {
|
|
46
|
+
if self.v != Self::CURRENT_VERSION {
|
|
47
|
+
anyhow::bail!("Unsupported envelope version: {}", self.v);
|
|
48
|
+
}
|
|
49
|
+
BASE64
|
|
50
|
+
.decode(&self.data_b64)
|
|
51
|
+
.map_err(|e| anyhow::anyhow!("Base64 decode failed: {}", e))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Get the node vault directory path
|
|
56
|
+
pub fn node_vault_dir(home: &Path) -> PathBuf {
|
|
57
|
+
home.join("vault").join("node")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Get the node vault values directory path
|
|
61
|
+
pub fn node_vault_values_dir(home: &Path) -> PathBuf {
|
|
62
|
+
node_vault_dir(home).join("values")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Get the path for a specific secret
|
|
66
|
+
fn secret_path(home: &Path, secret_id: &str) -> PathBuf {
|
|
67
|
+
node_vault_values_dir(home).join(format!("{}.enc", secret_id))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Ensure the node vault directory structure exists
|
|
71
|
+
fn ensure_dirs(home: &Path) -> anyhow::Result<()> {
|
|
72
|
+
let values_dir = node_vault_values_dir(home);
|
|
73
|
+
if !values_dir.exists() {
|
|
74
|
+
fs::create_dir_all(&values_dir)?;
|
|
75
|
+
|
|
76
|
+
// Set secure permissions on Unix
|
|
77
|
+
#[cfg(unix)]
|
|
78
|
+
{
|
|
79
|
+
let perms = fs::Permissions::from_mode(0o700);
|
|
80
|
+
fs::set_permissions(&node_vault_dir(home), perms.clone())?;
|
|
81
|
+
fs::set_permissions(&values_dir, perms)?;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
Ok(())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Write a secret to the node vault
|
|
88
|
+
///
|
|
89
|
+
/// Encrypts the plaintext using the derived key and writes atomically.
|
|
90
|
+
pub fn write_node_secret(
|
|
91
|
+
home: &Path,
|
|
92
|
+
epoch: u32,
|
|
93
|
+
secret_id: &str,
|
|
94
|
+
plaintext: &[u8],
|
|
95
|
+
) -> anyhow::Result<()> {
|
|
96
|
+
ensure_dirs(home)?;
|
|
97
|
+
|
|
98
|
+
// Derive key (device-bound, no node_id needed)
|
|
99
|
+
let key = derive_node_vault_key(home, epoch)?;
|
|
100
|
+
|
|
101
|
+
// Encrypt
|
|
102
|
+
let encrypted = encrypt_node_value(&key, plaintext)?;
|
|
103
|
+
|
|
104
|
+
// Create envelope
|
|
105
|
+
let envelope = EncryptedEnvelope::new(&encrypted);
|
|
106
|
+
let json = serde_json::to_string_pretty(&envelope)?;
|
|
107
|
+
|
|
108
|
+
// Atomic write: temp file + rename
|
|
109
|
+
let final_path = secret_path(home, secret_id);
|
|
110
|
+
let temp_path = final_path.with_extension("enc.tmp");
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
let mut file = fs::File::create(&temp_path)?;
|
|
114
|
+
file.write_all(json.as_bytes())?;
|
|
115
|
+
file.sync_all()?;
|
|
116
|
+
|
|
117
|
+
// Set secure permissions on Unix
|
|
118
|
+
#[cfg(unix)]
|
|
119
|
+
{
|
|
120
|
+
let perms = fs::Permissions::from_mode(0o600);
|
|
121
|
+
fs::set_permissions(&temp_path, perms)?;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fs::rename(&temp_path, &final_path)?;
|
|
126
|
+
|
|
127
|
+
tracing::info!(
|
|
128
|
+
op = "node_vault.write",
|
|
129
|
+
secret_id = secret_id,
|
|
130
|
+
"Node secret written"
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
Ok(())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Read a secret from the node vault
|
|
137
|
+
///
|
|
138
|
+
/// Returns None if the secret doesn't exist.
|
|
139
|
+
pub fn read_node_secret(
|
|
140
|
+
home: &Path,
|
|
141
|
+
epoch: u32,
|
|
142
|
+
secret_id: &str,
|
|
143
|
+
) -> anyhow::Result<Option<Vec<u8>>> {
|
|
144
|
+
let path = secret_path(home, secret_id);
|
|
145
|
+
|
|
146
|
+
if !path.exists() {
|
|
147
|
+
tracing::info!(
|
|
148
|
+
op = "node_vault.read",
|
|
149
|
+
secret_id = secret_id,
|
|
150
|
+
hit = false,
|
|
151
|
+
"Node secret not found"
|
|
152
|
+
);
|
|
153
|
+
return Ok(None);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Read envelope
|
|
157
|
+
let content = fs::read_to_string(&path)?;
|
|
158
|
+
let envelope: EncryptedEnvelope = serde_json::from_str(&content)?;
|
|
159
|
+
|
|
160
|
+
// Decode base64
|
|
161
|
+
let encrypted = envelope.decode()?;
|
|
162
|
+
|
|
163
|
+
// Derive key (device-bound, no node_id needed)
|
|
164
|
+
let key = derive_node_vault_key(home, epoch)?;
|
|
165
|
+
|
|
166
|
+
// Decrypt
|
|
167
|
+
let plaintext = decrypt_node_value(&key, &encrypted)?;
|
|
168
|
+
|
|
169
|
+
tracing::info!(
|
|
170
|
+
op = "node_vault.read",
|
|
171
|
+
secret_id = secret_id,
|
|
172
|
+
hit = true,
|
|
173
|
+
"Node secret read"
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
Ok(Some(plaintext))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Delete a secret from the node vault
|
|
180
|
+
pub fn delete_node_secret(home: &Path, secret_id: &str) -> anyhow::Result<()> {
|
|
181
|
+
let path = secret_path(home, secret_id);
|
|
182
|
+
|
|
183
|
+
if path.exists() {
|
|
184
|
+
fs::remove_file(&path)?;
|
|
185
|
+
tracing::info!(
|
|
186
|
+
op = "node_vault.delete",
|
|
187
|
+
secret_id = secret_id,
|
|
188
|
+
"Node secret deleted"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Ok(())
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Check if a secret exists in the node vault
|
|
196
|
+
pub fn has_node_secret(home: &Path, secret_id: &str) -> bool {
|
|
197
|
+
secret_path(home, secret_id).exists()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[cfg(test)]
|
|
201
|
+
mod tests {
|
|
202
|
+
use super::*;
|
|
203
|
+
use tempfile::TempDir;
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
fn test_write_read_roundtrip() {
|
|
207
|
+
let temp_dir = TempDir::new().unwrap();
|
|
208
|
+
let epoch = 1u32;
|
|
209
|
+
let secret_id = "test_secret";
|
|
210
|
+
let plaintext = b"my secret data";
|
|
211
|
+
|
|
212
|
+
// Write
|
|
213
|
+
write_node_secret(temp_dir.path(), epoch, secret_id, plaintext).unwrap();
|
|
214
|
+
|
|
215
|
+
// Read
|
|
216
|
+
let result = read_node_secret(temp_dir.path(), epoch, secret_id).unwrap();
|
|
217
|
+
|
|
218
|
+
assert!(result.is_some());
|
|
219
|
+
assert_eq!(result.unwrap(), plaintext.to_vec());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[test]
|
|
223
|
+
fn test_read_nonexistent_returns_none() {
|
|
224
|
+
let temp_dir = TempDir::new().unwrap();
|
|
225
|
+
let result = read_node_secret(temp_dir.path(), 1, "nonexistent").unwrap();
|
|
226
|
+
assert!(result.is_none());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#[test]
|
|
230
|
+
fn test_delete_secret() {
|
|
231
|
+
let temp_dir = TempDir::new().unwrap();
|
|
232
|
+
let epoch = 1u32;
|
|
233
|
+
let secret_id = "to_delete";
|
|
234
|
+
|
|
235
|
+
// Write
|
|
236
|
+
write_node_secret(temp_dir.path(), epoch, secret_id, b"data").unwrap();
|
|
237
|
+
assert!(has_node_secret(temp_dir.path(), secret_id));
|
|
238
|
+
|
|
239
|
+
// Delete
|
|
240
|
+
delete_node_secret(temp_dir.path(), secret_id).unwrap();
|
|
241
|
+
assert!(!has_node_secret(temp_dir.path(), secret_id));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#[test]
|
|
245
|
+
fn test_has_node_secret() {
|
|
246
|
+
let temp_dir = TempDir::new().unwrap();
|
|
247
|
+
|
|
248
|
+
assert!(!has_node_secret(temp_dir.path(), "test"));
|
|
249
|
+
|
|
250
|
+
write_node_secret(temp_dir.path(), 1, "test", b"data").unwrap();
|
|
251
|
+
|
|
252
|
+
assert!(has_node_secret(temp_dir.path(), "test"));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[test]
|
|
256
|
+
fn test_wrong_epoch_fails_decrypt() {
|
|
257
|
+
let temp_dir = TempDir::new().unwrap();
|
|
258
|
+
let secret_id = "epoch_test";
|
|
259
|
+
|
|
260
|
+
// Write with epoch 1
|
|
261
|
+
write_node_secret(temp_dir.path(), 1, secret_id, b"secret").unwrap();
|
|
262
|
+
|
|
263
|
+
// Read with epoch 2 should fail
|
|
264
|
+
let result = read_node_secret(temp_dir.path(), 2, secret_id);
|
|
265
|
+
assert!(result.is_err());
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! Auth operations
|
|
2
|
+
//!
|
|
3
|
+
//! Handles: auth.set
|
|
4
|
+
|
|
5
|
+
use crate::state::{AuthContext, EngineState};
|
|
6
|
+
use crate::types::EngineResponse;
|
|
7
|
+
use serde_json::{json, Value};
|
|
8
|
+
|
|
9
|
+
/// Handle auth.set operation
|
|
10
|
+
pub fn handle_set(payload: &Value, state: &EngineState) -> EngineResponse {
|
|
11
|
+
let tenant_id = match payload.get("tenantId").and_then(|v| v.as_str()) {
|
|
12
|
+
Some(t) if !t.is_empty() => t.to_string(),
|
|
13
|
+
_ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'tenantId'"),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let sub = match payload.get("sub").and_then(|v| v.as_str()) {
|
|
17
|
+
Some(s) if !s.is_empty() => s.to_string(),
|
|
18
|
+
_ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'sub'"),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let jwt = match payload.get("jwt").and_then(|v| v.as_str()) {
|
|
22
|
+
Some(j) if !j.is_empty() => j.to_string(),
|
|
23
|
+
_ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'jwt'"),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Workspace ID is optional - defaults to tenant_id if not provided
|
|
27
|
+
let workspace_id = payload
|
|
28
|
+
.get("workspaceId")
|
|
29
|
+
.and_then(|v| v.as_str())
|
|
30
|
+
.filter(|s| !s.is_empty())
|
|
31
|
+
.map(|s| s.to_string());
|
|
32
|
+
|
|
33
|
+
let auth_context = AuthContext {
|
|
34
|
+
tenant_id,
|
|
35
|
+
sub,
|
|
36
|
+
jwt,
|
|
37
|
+
workspace_id,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Clear vault cache when auth changes (new tenant/user means new encryption key)
|
|
41
|
+
state.clear_vault_cache();
|
|
42
|
+
|
|
43
|
+
match state.auth.lock() {
|
|
44
|
+
Ok(mut guard) => {
|
|
45
|
+
*guard = Some(auth_context);
|
|
46
|
+
EngineResponse::ok(json!({ "ok": true }))
|
|
47
|
+
}
|
|
48
|
+
Err(e) => EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
//! Home operations (DEPRECATED - use handlers/home.rs)
|
|
2
|
+
//!
|
|
3
|
+
//! Handles: home.status, home.grant
|
|
4
|
+
//!
|
|
5
|
+
//! This module is deprecated in favor of SDK-based handlers.
|
|
6
|
+
|
|
7
|
+
#![allow(dead_code)]
|
|
8
|
+
|
|
9
|
+
use crate::bootstrap::resolve_home_path;
|
|
10
|
+
use crate::grants::get_home_status;
|
|
11
|
+
use crate::state::EngineState;
|
|
12
|
+
use crate::types::EngineResponse;
|
|
13
|
+
use serde_json::{json, Value};
|
|
14
|
+
|
|
15
|
+
/// Handle home.status operation
|
|
16
|
+
pub fn handle_status(state: &EngineState) -> EngineResponse {
|
|
17
|
+
let (home_state, home_path, grant_present, reason) = get_home_status(state);
|
|
18
|
+
|
|
19
|
+
EngineResponse::ok(json!({
|
|
20
|
+
"state": home_state,
|
|
21
|
+
"homePath": home_path.to_string_lossy(),
|
|
22
|
+
"grantPresent": grant_present,
|
|
23
|
+
"reason": reason,
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Handle home.grant operation
|
|
28
|
+
pub fn handle_grant(state: &EngineState) -> EngineResponse {
|
|
29
|
+
// 1. Must have auth with JWT
|
|
30
|
+
let auth = match state.auth.lock() {
|
|
31
|
+
Ok(guard) => guard.clone(),
|
|
32
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let auth = match auth {
|
|
36
|
+
Some(a) => a,
|
|
37
|
+
None => {
|
|
38
|
+
return EngineResponse::err("NOT_AUTHENTICATED", "Must call auth.set before home.grant")
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// 2. Get home path
|
|
43
|
+
let home_path = match state.home_path.lock() {
|
|
44
|
+
Ok(guard) => guard.clone(),
|
|
45
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let home_path = match home_path {
|
|
49
|
+
Some(p) => p,
|
|
50
|
+
None => match resolve_home_path() {
|
|
51
|
+
Ok(p) => p,
|
|
52
|
+
Err(e) => return EngineResponse::err("HOME_PATH_ERROR", &e),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// 3. Load marker to get instance_id (used as node_id in grants)
|
|
57
|
+
let marker_path = home_path.join(".ekka-marker.json");
|
|
58
|
+
let marker_content = match std::fs::read_to_string(&marker_path) {
|
|
59
|
+
Ok(c) => c,
|
|
60
|
+
Err(e) => {
|
|
61
|
+
return EngineResponse::err(
|
|
62
|
+
"MARKER_READ_ERROR",
|
|
63
|
+
&format!("Failed to read marker: {}", e),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let marker: Value = match serde_json::from_str(&marker_content) {
|
|
69
|
+
Ok(m) => m,
|
|
70
|
+
Err(e) => {
|
|
71
|
+
return EngineResponse::err(
|
|
72
|
+
"MARKER_PARSE_ERROR",
|
|
73
|
+
&format!("Failed to parse marker: {}", e),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let node_id = match marker.get("instance_id").and_then(|v| v.as_str()) {
|
|
79
|
+
Some(n) => n.to_string(),
|
|
80
|
+
None => return EngineResponse::err("MARKER_INVALID", "Marker missing instance_id"),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// 4. Get engine URL
|
|
84
|
+
let engine_url = match std::env::var("EKKA_ENGINE_URL") {
|
|
85
|
+
Ok(u) => u,
|
|
86
|
+
Err(_) => {
|
|
87
|
+
return EngineResponse::err(
|
|
88
|
+
"ENGINE_NOT_CONFIGURED",
|
|
89
|
+
"EKKA_ENGINE_URL not set. HOME grant requires online engine.",
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// 5. Build grant request
|
|
95
|
+
let grant_request = json!({
|
|
96
|
+
"resource": {
|
|
97
|
+
"kind": "path",
|
|
98
|
+
"path_prefix": home_path.to_string_lossy(),
|
|
99
|
+
"attrs": {
|
|
100
|
+
"path_type": "HOME"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"permissions": {
|
|
104
|
+
"ops": ["read", "write", "delete"],
|
|
105
|
+
"access": "READ_WRITE"
|
|
106
|
+
},
|
|
107
|
+
"purpose": "home_bootstrap",
|
|
108
|
+
"expires_in_seconds": 31536000,
|
|
109
|
+
"node_id": node_id,
|
|
110
|
+
"consent": {
|
|
111
|
+
"mode": "user_click"
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 6. Make HTTP request to engine
|
|
116
|
+
let client = match reqwest::blocking::Client::builder()
|
|
117
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
118
|
+
.build()
|
|
119
|
+
{
|
|
120
|
+
Ok(c) => c,
|
|
121
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
125
|
+
let response = match client
|
|
126
|
+
.post(format!("{}/engine/grants/issue", engine_url))
|
|
127
|
+
.header("Authorization", format!("Bearer {}", auth.jwt))
|
|
128
|
+
.header("Content-Type", "application/json")
|
|
129
|
+
.header("X-EKKA-PROOF-TYPE", "jwt")
|
|
130
|
+
.header("X-REQUEST-ID", &request_id)
|
|
131
|
+
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
132
|
+
.header("X-EKKA-MODULE", "desktop.home")
|
|
133
|
+
.header("X-EKKA-ACTION", "grant")
|
|
134
|
+
.header("X-EKKA-CLIENT", "desktop")
|
|
135
|
+
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
136
|
+
.json(&grant_request)
|
|
137
|
+
.send()
|
|
138
|
+
{
|
|
139
|
+
Ok(r) => r,
|
|
140
|
+
Err(e) => {
|
|
141
|
+
return EngineResponse::err(
|
|
142
|
+
"ENGINE_REQUEST_FAILED",
|
|
143
|
+
&format!("HTTP request failed: {}", e),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// 7. Check response status
|
|
149
|
+
if !response.status().is_success() {
|
|
150
|
+
let status = response.status();
|
|
151
|
+
let error_body = response.text().unwrap_or_else(|_| "No error body".to_string());
|
|
152
|
+
return EngineResponse::err(
|
|
153
|
+
"ENGINE_GRANT_DENIED",
|
|
154
|
+
&format!("Engine returned {}: {}", status, error_body),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 8. Parse response
|
|
159
|
+
let grant_response: Value = match response.json() {
|
|
160
|
+
Ok(v) => v,
|
|
161
|
+
Err(e) => {
|
|
162
|
+
return EngineResponse::err(
|
|
163
|
+
"RESPONSE_PARSE_ERROR",
|
|
164
|
+
&format!("Failed to parse response: {}", e),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// 9. Extract signed grant
|
|
170
|
+
let signed_grant = match grant_response.get("signed_grant") {
|
|
171
|
+
Some(sg) => sg.clone(),
|
|
172
|
+
None => return EngineResponse::err("INVALID_RESPONSE", "Response missing signed_grant"),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let grant_id = signed_grant
|
|
176
|
+
.get("grant")
|
|
177
|
+
.and_then(|g| g.get("grant_id"))
|
|
178
|
+
.and_then(|id| id.as_str())
|
|
179
|
+
.unwrap_or("unknown")
|
|
180
|
+
.to_string();
|
|
181
|
+
|
|
182
|
+
let expires_at = grant_response
|
|
183
|
+
.get("expires_at")
|
|
184
|
+
.and_then(|e| e.as_str())
|
|
185
|
+
.map(|s| s.to_string());
|
|
186
|
+
|
|
187
|
+
// 10. Load or create grants.json
|
|
188
|
+
let grants_path = home_path.join("grants.json");
|
|
189
|
+
let mut grants_file: Value = if grants_path.exists() {
|
|
190
|
+
match std::fs::read_to_string(&grants_path) {
|
|
191
|
+
Ok(content) => serde_json::from_str(&content).unwrap_or(json!({
|
|
192
|
+
"schema_version": "1.0",
|
|
193
|
+
"grants": []
|
|
194
|
+
})),
|
|
195
|
+
Err(_) => json!({
|
|
196
|
+
"schema_version": "1.0",
|
|
197
|
+
"grants": []
|
|
198
|
+
}),
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
json!({
|
|
202
|
+
"schema_version": "1.0",
|
|
203
|
+
"grants": []
|
|
204
|
+
})
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// 11. Add grant to grants array
|
|
208
|
+
let path_grant = json!({
|
|
209
|
+
"schema": signed_grant.get("schema"),
|
|
210
|
+
"canon_alg": signed_grant.get("canon_alg"),
|
|
211
|
+
"signing_alg": signed_grant.get("signing_alg"),
|
|
212
|
+
"grant": signed_grant.get("grant"),
|
|
213
|
+
"grant_canonical_b64": signed_grant.get("grant_canonical_b64"),
|
|
214
|
+
"signature_b64": signed_grant.get("signature_b64"),
|
|
215
|
+
"path_type": "HOME",
|
|
216
|
+
"path_access": "READ_WRITE"
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if let Some(grants_array) = grants_file.get_mut("grants").and_then(|g| g.as_array_mut()) {
|
|
220
|
+
grants_array.push(path_grant);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 12. Write grants.json atomically
|
|
224
|
+
let grants_json = match serde_json::to_string_pretty(&grants_file) {
|
|
225
|
+
Ok(j) => j,
|
|
226
|
+
Err(e) => {
|
|
227
|
+
return EngineResponse::err(
|
|
228
|
+
"SERIALIZE_ERROR",
|
|
229
|
+
&format!("Failed to serialize grants: {}", e),
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
let temp_path = grants_path.with_extension("json.tmp");
|
|
235
|
+
if let Err(e) = std::fs::write(&temp_path, &grants_json) {
|
|
236
|
+
return EngineResponse::err("WRITE_ERROR", &format!("Failed to write grants: {}", e));
|
|
237
|
+
}
|
|
238
|
+
if let Err(e) = std::fs::rename(&temp_path, &grants_path) {
|
|
239
|
+
return EngineResponse::err(
|
|
240
|
+
"RENAME_ERROR",
|
|
241
|
+
&format!("Failed to rename grants file: {}", e),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 13. Return success
|
|
246
|
+
EngineResponse::ok(json!({
|
|
247
|
+
"success": true,
|
|
248
|
+
"grant_id": grant_id,
|
|
249
|
+
"expires_at": expires_at,
|
|
250
|
+
}))
|
|
251
|
+
}
|