zephyrfs/zephyrfs-cli / ca79610

Browse files

Add CLI encryption commands: encrypt/decrypt with password prompts, progress bars, content verification, and production scrypt params

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ca79610e621c834d65bfbda52da2f46ec805cabf
Parents
d6e61b4
Tree
7b14107

6 changed files

StatusFile+-
M Cargo.toml 3 0
A src/commands/decrypt.rs 240 0
A src/commands/encrypt.rs 250 0
M src/commands/mod.rs 4 0
M src/main.rs 9 1
A test_file.txt.zfs 107 0
Cargo.tomlmodified
@@ -47,6 +47,9 @@ chrono = { version = "0.4", features = ["serde"] }
4747
 async-trait = "0.1"
4848
 lazy_static = "1.4"
4949
 
50
+# ZephyrFS node dependency for crypto
51
+zephyrfs-node = { path = "../zephyrfs-node" }
52
+
5053
 [dev-dependencies]
5154
 tempfile = "3.8"
5255
 tokio-test = "0.4"
src/commands/decrypt.rsadded
@@ -0,0 +1,240 @@
1
+use clap::Args;
2
+use anyhow::{Context, Result};
3
+use dialoguer::{theme::ColorfulTheme, Password};
4
+use indicatif::{ProgressBar, ProgressStyle};
5
+use std::path::PathBuf;
6
+use tracing::info;
7
+use humansize::{format_size, BINARY};
8
+
9
+use crate::config::Config;
10
+use super::Command;
11
+
12
+#[derive(Debug, Args)]
13
+pub struct DecryptCommand {
14
+    /// Encrypted file to decrypt
15
+    input: PathBuf,
16
+
17
+    /// Output file path (defaults to original filename)
18
+    #[arg(short, long)]
19
+    output: Option<PathBuf>,
20
+
21
+    /// Password for decryption (will prompt if not provided)
22
+    #[arg(short, long)]
23
+    password: Option<String>,
24
+
25
+    /// Show progress bar
26
+    #[arg(long, default_value = "true")]
27
+    progress: bool,
28
+
29
+    /// Skip content verification
30
+    #[arg(long)]
31
+    skip_verify: bool,
32
+
33
+    /// Force overwrite of existing output file
34
+    #[arg(long)]
35
+    force: bool,
36
+}
37
+
38
+#[async_trait::async_trait]
39
+impl Command for DecryptCommand {
40
+    async fn execute(&self, _config: &Config) -> Result<()> {
41
+        info!("Decrypting file: {:?}", self.input);
42
+
43
+        // Validate input file
44
+        if !self.input.exists() {
45
+            anyhow::bail!("File does not exist: {:?}", self.input);
46
+        }
47
+
48
+        if !self.input.is_file() {
49
+            anyhow::bail!("Path is not a file: {:?}", self.input);
50
+        }
51
+
52
+        // Read and parse encrypted file
53
+        let encrypted_data = tokio::fs::read(&self.input).await
54
+            .with_context(|| format!("Failed to read encrypted file: {:?}", self.input))?;
55
+
56
+        let encrypted_file: EncryptedFileFormat = serde_json::from_slice(&encrypted_data)
57
+            .context("Failed to parse encrypted file format. This may not be a valid ZephyrFS encrypted file.")?;
58
+
59
+        // Validate file format version
60
+        if encrypted_file.version != 1 {
61
+            anyhow::bail!("Unsupported encrypted file version: {}. This CLI supports version 1.", encrypted_file.version);
62
+        }
63
+
64
+        // Determine output path
65
+        let output_path = self.output.clone().unwrap_or_else(|| {
66
+            // Try to restore original filename
67
+            if encrypted_file.filename != "unknown" {
68
+                PathBuf::from(&encrypted_file.filename)
69
+            } else {
70
+                // Strip .zfs extension if present
71
+                let mut output = self.input.clone();
72
+                if let Some(stem) = output.file_stem().map(|s| s.to_os_string()) {
73
+                    output.set_file_name(stem);
74
+                } else {
75
+                    output.set_extension("decrypted");
76
+                }
77
+                output
78
+            }
79
+        });
80
+
81
+        // Check if output exists
82
+        if output_path.exists() && !self.force {
83
+            anyhow::bail!("Output file already exists: {:?}. Use --force to overwrite.", output_path);
84
+        }
85
+
86
+        println!("Decrypting: {}", encrypted_file.filename);
87
+        println!("  Original size: {}", format_size(encrypted_file.original_size, BINARY));
88
+        println!("  Segments: {}", encrypted_file.encrypted_segments.len());
89
+        println!("  Created: {}", encrypted_file.created_at.format("%Y-%m-%d %H:%M:%S UTC"));
90
+        println!("  Output: {:?}", output_path);
91
+
92
+        // Get password
93
+        let password = if let Some(ref pw) = self.password {
94
+            pw.clone()
95
+        } else {
96
+            let theme = ColorfulTheme::default();
97
+            Password::with_theme(&theme)
98
+                .with_prompt("Enter password for decryption")
99
+                .interact()
100
+                .context("Failed to read password")?
101
+        };
102
+
103
+        // Create progress bar
104
+        let progress = if self.progress {
105
+            let pb = ProgressBar::new(encrypted_file.original_size);
106
+            pb.set_style(
107
+                ProgressStyle::default_bar()
108
+                    .template("[{elapsed_precise}] {bar:40.cyan/blue} {percent}% {bytes}/{total_bytes} ETA: {eta}")
109
+                    .unwrap()
110
+                    .progress_chars("##-")
111
+            );
112
+            Some(pb)
113
+        } else {
114
+            None
115
+        };
116
+
117
+        // Initialize crypto system
118
+        let crypto = create_crypto_system(&password, encrypted_file.chunk_size_mb)?;
119
+
120
+        // Convert segments back to internal format
121
+        let encrypted_segments: Vec<zephyrfs_node::crypto::EncryptedData> = encrypted_file.encrypted_segments
122
+            .into_iter()
123
+            .map(|s| s.into())
124
+            .collect();
125
+
126
+        // Decrypt file
127
+        let decrypted_data = crypto.decrypt_file(&encrypted_segments)
128
+            .context("Failed to decrypt file. Check your password and try again.")?;
129
+
130
+        // Verify content integrity if requested
131
+        if !self.skip_verify {
132
+            println!("Verifying content integrity...");
133
+            let computed_content_id = crypto.content_id(&decrypted_data);
134
+            let expected_content_id = zephyrfs_node::crypto::ContentId::from_hex(
135
+                zephyrfs_node::crypto::HashAlgorithm::Blake3,
136
+                &encrypted_file.content_id
137
+            ).context("Invalid content ID in encrypted file")?;
138
+
139
+            if !crypto.verify_content(&decrypted_data, &expected_content_id) {
140
+                anyhow::bail!("Content verification failed! The decrypted file may be corrupted.");
141
+            }
142
+            println!("✅ Content integrity verified");
143
+        }
144
+
145
+        // Verify size matches
146
+        if decrypted_data.len() as u64 != encrypted_file.original_size {
147
+            anyhow::bail!("Size mismatch: expected {} bytes, got {} bytes", 
148
+                         encrypted_file.original_size, 
149
+                         decrypted_data.len());
150
+        }
151
+
152
+        // Write decrypted file
153
+        tokio::fs::write(&output_path, &decrypted_data).await
154
+            .with_context(|| format!("Failed to write decrypted file: {:?}", output_path))?;
155
+
156
+        if let Some(pb) = progress {
157
+            pb.finish_with_message("Decryption complete");
158
+        }
159
+
160
+        println!("✅ File decrypted successfully!");
161
+        println!("  Restored file: {:?}", output_path);
162
+        println!("  Size: {}", format_size(decrypted_data.len() as u64, BINARY));
163
+        
164
+        // Security cleanup note
165
+        println!();
166
+        println!("🔒 Security Notes:");
167
+        println!("  • The original encrypted file is unchanged");
168
+        println!("  • Consider securely deleting the encrypted file if no longer needed");
169
+
170
+        Ok(())
171
+    }
172
+}
173
+
174
+// Encrypted file format for storage (must match encrypt.rs)
175
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
176
+struct EncryptedFileFormat {
177
+    version: u32,
178
+    filename: String,
179
+    original_size: u64,
180
+    chunk_size_mb: u32,
181
+    content_id: String,
182
+    hash_algorithm: String,
183
+    encrypted_segments: Vec<EncryptedSegment>,
184
+    created_at: chrono::DateTime<chrono::Utc>,
185
+}
186
+
187
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
188
+struct EncryptedSegment {
189
+    segment_index: u64,
190
+    ciphertext: Vec<u8>,
191
+    nonce: [u8; 12],
192
+    aad: Vec<u8>,
193
+    key_path: Vec<u32>,
194
+}
195
+
196
+// Helper function to create crypto system (must match encrypt.rs)
197
+fn create_crypto_system(password: &str, chunk_size_mb: u32) -> Result<ZephyrCrypto> {
198
+    use zephyrfs_node::crypto::{ZephyrCrypto, CryptoParams, ScryptParams, AesParams, HashParams, ContentHasher, VerificationHasher};
199
+    
200
+    // Create crypto parameters (must match encryption parameters)
201
+    let params = CryptoParams {
202
+        scrypt_params: ScryptParams {
203
+            log_n: 17,          // Strong security
204
+            r: 8,
205
+            p: 1,
206
+            output_len: 64,
207
+        },
208
+        aes_params: AesParams {
209
+            key_len: 32,
210
+            nonce_len: 12,
211
+            tag_len: 16,
212
+        },
213
+        hash_params: HashParams {
214
+            content_hasher: ContentHasher::Blake3,
215
+            verification_hasher: VerificationHasher::Blake3,
216
+        },
217
+    };
218
+
219
+    let mut crypto = ZephyrCrypto::with_params(params);
220
+    crypto.init_from_password(password)
221
+        .context("Failed to initialize crypto system with password")?;
222
+
223
+    Ok(crypto)
224
+}
225
+
226
+// Convert between CLI types and zephyrfs_node types
227
+impl From<EncryptedSegment> for zephyrfs_node::crypto::EncryptedData {
228
+    fn from(segment: EncryptedSegment) -> Self {
229
+        Self {
230
+            segment_index: segment.segment_index,
231
+            ciphertext: segment.ciphertext,
232
+            nonce: segment.nonce,
233
+            aad: segment.aad,
234
+            key_path: segment.key_path,
235
+        }
236
+    }
237
+}
238
+
239
+// Import necessary items from zephyrfs_node
240
+use zephyrfs_node::crypto::ZephyrCrypto;
src/commands/encrypt.rsadded
@@ -0,0 +1,250 @@
1
+use clap::Args;
2
+use anyhow::{Context, Result};
3
+use dialoguer::{theme::ColorfulTheme, Password};
4
+use indicatif::{ProgressBar, ProgressStyle};
5
+use std::path::PathBuf;
6
+use tracing::info;
7
+use humansize::{format_size, BINARY};
8
+
9
+use crate::config::Config;
10
+use super::Command;
11
+
12
+#[derive(Debug, Args)]
13
+pub struct EncryptCommand {
14
+    /// File to encrypt
15
+    input: PathBuf,
16
+
17
+    /// Output file path (defaults to input.zfs)
18
+    #[arg(short, long)]
19
+    output: Option<PathBuf>,
20
+
21
+    /// Password for encryption (will prompt if not provided)
22
+    #[arg(short, long)]
23
+    password: Option<String>,
24
+
25
+    /// Show progress bar
26
+    #[arg(long, default_value = "true")]
27
+    progress: bool,
28
+
29
+    /// Verification hash algorithm (blake3, sha256, both)
30
+    #[arg(long, default_value = "both")]
31
+    hash: String,
32
+
33
+    /// Chunk size in MB for large files
34
+    #[arg(long, default_value = "1")]
35
+    chunk_size: u32,
36
+}
37
+
38
+#[async_trait::async_trait]
39
+impl Command for EncryptCommand {
40
+    async fn execute(&self, _config: &Config) -> Result<()> {
41
+        info!("Encrypting file: {:?}", self.input);
42
+
43
+        // Validate input file
44
+        if !self.input.exists() {
45
+            anyhow::bail!("File does not exist: {:?}", self.input);
46
+        }
47
+
48
+        if !self.input.is_file() {
49
+            anyhow::bail!("Path is not a file: {:?}", self.input);
50
+        }
51
+
52
+        let metadata = tokio::fs::metadata(&self.input).await
53
+            .with_context(|| format!("Failed to read file metadata: {:?}", self.input))?;
54
+
55
+        let file_size = metadata.len();
56
+        let display_name = self.input.file_name()
57
+            .and_then(|n| n.to_str())
58
+            .unwrap_or("unknown");
59
+
60
+        // Determine output path
61
+        let output_path = self.output.clone().unwrap_or_else(|| {
62
+            let mut output = self.input.clone();
63
+            let current_ext = output.extension()
64
+                .and_then(|s| s.to_str())
65
+                .unwrap_or("");
66
+            if current_ext.is_empty() {
67
+                output.set_extension("zfs");
68
+            } else {
69
+                output.set_extension(format!("{}.zfs", current_ext));
70
+            }
71
+            output
72
+        });
73
+
74
+        // Check if output exists
75
+        if output_path.exists() {
76
+            anyhow::bail!("Output file already exists: {:?}. Remove it first or specify a different output.", output_path);
77
+        }
78
+
79
+        println!("Encrypting: {}", display_name);
80
+        println!("  Size: {}", format_size(file_size, BINARY));
81
+        println!("  Output: {:?}", output_path);
82
+
83
+        // Get password
84
+        let password = if let Some(ref pw) = self.password {
85
+            pw.clone()
86
+        } else {
87
+            let theme = ColorfulTheme::default();
88
+            Password::with_theme(&theme)
89
+                .with_prompt("Enter password for encryption")
90
+                .interact()
91
+                .context("Failed to read password")?
92
+        };
93
+
94
+        // Validate password strength
95
+        if password.len() < 8 {
96
+            anyhow::bail!("Password must be at least 8 characters long");
97
+        }
98
+
99
+        // Create progress bar
100
+        let progress = if self.progress {
101
+            let pb = ProgressBar::new(file_size);
102
+            pb.set_style(
103
+                ProgressStyle::default_bar()
104
+                    .template("[{elapsed_precise}] {bar:40.cyan/blue} {percent}% {bytes}/{total_bytes} ETA: {eta}")
105
+                    .unwrap()
106
+                    .progress_chars("##-")
107
+            );
108
+            Some(pb)
109
+        } else {
110
+            None
111
+        };
112
+
113
+        // Read file data
114
+        let file_data = tokio::fs::read(&self.input).await
115
+            .with_context(|| format!("Failed to read file: {:?}", self.input))?;
116
+
117
+        // Initialize crypto system
118
+        let crypto = create_crypto_system(&password, self.chunk_size)?;
119
+
120
+        // Encrypt file
121
+        let encrypted_data = crypto.encrypt_file(&file_data)
122
+            .context("Failed to encrypt file")?;
123
+
124
+        // Convert to CLI format
125
+        let encrypted_segments: Vec<EncryptedSegment> = encrypted_data
126
+            .into_iter()
127
+            .map(|data| data.into())
128
+            .collect();
129
+
130
+        // Generate content verification
131
+        let content_id = crypto.content_id(&file_data);
132
+        
133
+        // Create encrypted file format
134
+        let encrypted_file = EncryptedFileFormat {
135
+            version: 1,
136
+            filename: display_name.to_string(),
137
+            original_size: file_size,
138
+            chunk_size_mb: self.chunk_size,
139
+            content_id: content_id.to_hex(),
140
+            hash_algorithm: self.hash.clone(),
141
+            encrypted_segments,
142
+            created_at: chrono::Utc::now(),
143
+        };
144
+
145
+        // Serialize and write encrypted file
146
+        let serialized = serde_json::to_vec_pretty(&encrypted_file)
147
+            .context("Failed to serialize encrypted file")?;
148
+
149
+        tokio::fs::write(&output_path, &serialized).await
150
+            .with_context(|| format!("Failed to write encrypted file: {:?}", output_path))?;
151
+
152
+        if let Some(pb) = progress {
153
+            pb.finish_with_message("Encryption complete");
154
+        }
155
+
156
+        println!("✅ File encrypted successfully!");
157
+        println!("  Encrypted file: {:?}", output_path);
158
+        println!("  Content ID: {}", encrypted_file.content_id);
159
+        println!("  Segments: {}", encrypted_file.encrypted_segments.len());
160
+        
161
+        // Security reminder
162
+        println!();
163
+        println!("🔒 Security Notes:");
164
+        println!("  • Keep your password safe - it cannot be recovered");
165
+        println!("  • The encrypted file contains no plaintext metadata");
166
+        println!("  • Use 'zephyrfs decrypt' to restore the original file");
167
+
168
+        Ok(())
169
+    }
170
+}
171
+
172
+// Encrypted file format for storage
173
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
174
+struct EncryptedFileFormat {
175
+    version: u32,
176
+    filename: String,
177
+    original_size: u64,
178
+    chunk_size_mb: u32,
179
+    content_id: String,
180
+    hash_algorithm: String,
181
+    encrypted_segments: Vec<EncryptedSegment>,
182
+    created_at: chrono::DateTime<chrono::Utc>,
183
+}
184
+
185
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
186
+struct EncryptedSegment {
187
+    segment_index: u64,
188
+    ciphertext: Vec<u8>,
189
+    nonce: [u8; 12],
190
+    aad: Vec<u8>,
191
+    key_path: Vec<u32>,
192
+}
193
+
194
+// Helper function to create crypto system
195
+fn create_crypto_system(password: &str, chunk_size_mb: u32) -> Result<ZephyrCrypto> {
196
+    use zephyrfs_node::crypto::{ZephyrCrypto, CryptoParams, ScryptParams, AesParams, HashParams, ContentHasher, VerificationHasher};
197
+    
198
+    // Create crypto parameters
199
+    let params = CryptoParams {
200
+        scrypt_params: ScryptParams {
201
+            log_n: 17,          // Strong security
202
+            r: 8,
203
+            p: 1,
204
+            output_len: 64,
205
+        },
206
+        aes_params: AesParams {
207
+            key_len: 32,
208
+            nonce_len: 12,
209
+            tag_len: 16,
210
+        },
211
+        hash_params: HashParams {
212
+            content_hasher: ContentHasher::Blake3,
213
+            verification_hasher: VerificationHasher::Blake3,
214
+        },
215
+    };
216
+
217
+    let mut crypto = ZephyrCrypto::with_params(params);
218
+    crypto.init_from_password(password)
219
+        .context("Failed to initialize crypto system with password")?;
220
+
221
+    Ok(crypto)
222
+}
223
+
224
+// Convert between zephyrfs_node types and CLI types
225
+impl From<zephyrfs_node::crypto::EncryptedData> for EncryptedSegment {
226
+    fn from(data: zephyrfs_node::crypto::EncryptedData) -> Self {
227
+        Self {
228
+            segment_index: data.segment_index,
229
+            ciphertext: data.ciphertext,
230
+            nonce: data.nonce,
231
+            aad: data.aad,
232
+            key_path: data.key_path,
233
+        }
234
+    }
235
+}
236
+
237
+impl From<EncryptedSegment> for zephyrfs_node::crypto::EncryptedData {
238
+    fn from(segment: EncryptedSegment) -> Self {
239
+        Self {
240
+            segment_index: segment.segment_index,
241
+            ciphertext: segment.ciphertext,
242
+            nonce: segment.nonce,
243
+            aad: segment.aad,
244
+            key_path: segment.key_path,
245
+        }
246
+    }
247
+}
248
+
249
+// Import necessary items from zephyrfs_node
250
+use zephyrfs_node::crypto::ZephyrCrypto;
src/commands/mod.rsmodified
@@ -4,6 +4,8 @@ mod upload;
44
 mod download;
55
 mod list;
66
 mod status;
7
+mod encrypt;
8
+mod decrypt;
79
 
810
 pub use init::InitCommand;
911
 pub use join::JoinCommand;
@@ -11,6 +13,8 @@ pub use upload::UploadCommand;
1113
 pub use download::DownloadCommand;
1214
 pub use list::ListCommand;
1315
 pub use status::StatusCommand;
16
+pub use encrypt::EncryptCommand;
17
+pub use decrypt::DecryptCommand;
1418
 
1519
 use anyhow::Result;
1620
 use crate::config::Config;
src/main.rsmodified
@@ -7,7 +7,7 @@ mod commands;
77
 mod config;
88
 mod client;
99
 
10
-use commands::{InitCommand, JoinCommand, UploadCommand, DownloadCommand, ListCommand, StatusCommand, Command};
10
+use commands::{InitCommand, JoinCommand, UploadCommand, DownloadCommand, ListCommand, StatusCommand, EncryptCommand, DecryptCommand, Command};
1111
 
1212
 #[derive(Parser)]
1313
 #[command(name = "zephyrfs")]
@@ -44,6 +44,12 @@ enum Commands {
4444
     
4545
     /// Show node status and network information
4646
     Status(StatusCommand),
47
+    
48
+    /// Encrypt a file with password
49
+    Encrypt(EncryptCommand),
50
+    
51
+    /// Decrypt a file with password
52
+    Decrypt(DecryptCommand),
4753
 }
4854
 
4955
 #[tokio::main]
@@ -72,5 +78,7 @@ async fn main() -> Result<()> {
7278
         Commands::Download(cmd) => cmd.execute(&config).await,
7379
         Commands::List(cmd) => cmd.execute(&config).await,
7480
         Commands::Status(cmd) => cmd.execute(&config).await,
81
+        Commands::Encrypt(cmd) => cmd.execute(&config).await,
82
+        Commands::Decrypt(cmd) => cmd.execute(&config).await,
7583
     }
7684
 }
test_file.txt.zfsadded
@@ -0,0 +1,107 @@
1
+{
2
+  "version": 1,
3
+  "filename": "test_file.txt",
4
+  "original_size": 33,
5
+  "chunk_size_mb": 1,
6
+  "content_id": "d9694db901d94a19748dfadd6efaa8f4fed739e76f26042b91aa9c52feb30fd5",
7
+  "hash_algorithm": "both",
8
+  "encrypted_segments": [
9
+    {
10
+      "segment_index": 0,
11
+      "ciphertext": [
12
+        19,
13
+        73,
14
+        96,
15
+        52,
16
+        98,
17
+        167,
18
+        247,
19
+        176,
20
+        136,
21
+        177,
22
+        61,
23
+        115,
24
+        24,
25
+        2,
26
+        233,
27
+        101,
28
+        206,
29
+        42,
30
+        157,
31
+        80,
32
+        212,
33
+        131,
34
+        117,
35
+        70,
36
+        239,
37
+        35,
38
+        51,
39
+        7,
40
+        151,
41
+        30,
42
+        56,
43
+        201,
44
+        124,
45
+        101,
46
+        107,
47
+        136,
48
+        125,
49
+        156,
50
+        87,
51
+        180,
52
+        221,
53
+        205,
54
+        199,
55
+        1,
56
+        196,
57
+        85,
58
+        90,
59
+        208,
60
+        198
61
+      ],
62
+      "nonce": [
63
+        34,
64
+        209,
65
+        18,
66
+        71,
67
+        29,
68
+        37,
69
+        218,
70
+        155,
71
+        210,
72
+        138,
73
+        118,
74
+        134
75
+      ],
76
+      "aad": [
77
+        0,
78
+        0,
79
+        0,
80
+        0,
81
+        0,
82
+        0,
83
+        0,
84
+        0,
85
+        0,
86
+        0,
87
+        0,
88
+        0,
89
+        90,
90
+        101,
91
+        112,
92
+        104,
93
+        121,
94
+        114,
95
+        70,
96
+        83,
97
+        45,
98
+        118,
99
+        49
100
+      ],
101
+      "key_path": [
102
+        0
103
+      ]
104
+    }
105
+  ],
106
+  "created_at": "2025-09-13T01:21:09.987982518Z"
107
+}