1use clap::{Parser, Subcommand};
26use std::fs;
27use std::io::{BufReader, BufWriter, Read as _};
28use std::path::Path;
29
30use sha2::{Digest, Sha256};
31use tbz_core::envelope::TibetEnvelope;
32use tbz_core::manifest::{BlockEntry, Manifest};
33use tbz_core::stream::{TbzReader, TbzWriter};
34use tbz_core::v2;
35use tbz_core::{signature, BlockType};
36
37pub mod tibet_zip;
38
39#[derive(Debug, Clone, Copy, PartialEq)]
45enum ArchiveFormat {
46 TbzBlock,
48 TibetZip,
50 Unknown,
52}
53
54fn detect_format(path: &str) -> anyhow::Result<ArchiveFormat> {
56 let mut file = fs::File::open(path)?;
57 let mut magic = [0u8; 4];
58 let n = std::io::Read::read(&mut file, &mut magic)?;
59 if n < 3 {
60 return Ok(ArchiveFormat::Unknown);
61 }
62 if magic[0..3] == [0x54, 0x42, 0x5A] {
63 Ok(ArchiveFormat::TbzBlock)
64 } else if magic == [0x50, 0x4B, 0x03, 0x04] {
65 Ok(ArchiveFormat::TibetZip)
66 } else {
67 Ok(ArchiveFormat::Unknown)
68 }
69}
70
71mod mirror_client {
75 use serde::{Deserialize, Serialize};
76
77 const TIMEOUT_SECS: u64 = 5;
78
79 #[derive(Serialize)]
80 pub struct RegisterPayload {
81 pub content_hash: String,
82 pub signing_key: String,
83 pub jis_id: Option<String>,
84 pub source_repo: Option<String>,
85 pub block_count: u32,
86 pub total_size: u64,
87 }
88
89 #[derive(Deserialize)]
90 pub struct RegisterResponse {
91 pub status: String, }
93
94 #[derive(Deserialize)]
95 pub struct LookupEntry {
96 pub content_hash: String,
97 pub first_seen: String,
98 pub attestations: Vec<LookupAttestation>,
99 }
100
101 #[derive(Deserialize)]
102 pub struct LookupAttestation {
103 pub verdict: String,
104 }
105
106 pub fn register(base_url: &str, payload: &RegisterPayload) -> Result<RegisterResponse, String> {
107 let url = format!("{}/api/tbz-mirror/register", base_url.trim_end_matches('/'));
108 let resp = ureq::post(&url)
109 .timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
110 .send_json(serde_json::json!({
111 "content_hash": payload.content_hash,
112 "signing_key": payload.signing_key,
113 "jis_id": payload.jis_id,
114 "source_repo": payload.source_repo,
115 "block_count": payload.block_count,
116 "total_size": payload.total_size,
117 }))
118 .map_err(|e| e.to_string())?;
119
120 resp.into_json::<RegisterResponse>().map_err(|e| e.to_string())
121 }
122
123 pub fn lookup(base_url: &str, hash: &str) -> Result<Option<LookupEntry>, String> {
124 let url = format!(
125 "{}/api/tbz-mirror/lookup/{}",
126 base_url.trim_end_matches('/'),
127 hash,
128 );
129 let resp = ureq::get(&url)
130 .timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
131 .call();
132
133 match resp {
134 Ok(r) => {
135 let entry = r.into_json::<LookupEntry>().map_err(|e| e.to_string())?;
136 Ok(Some(entry))
137 }
138 Err(ureq::Error::Status(404, _)) => Ok(None),
139 Err(e) => Err(e.to_string()),
140 }
141 }
142}
143
144fn hash_file(path: &Path) -> anyhow::Result<String> {
146 let file = fs::File::open(path)?;
147 let mut reader = BufReader::new(file);
148 let mut hasher = Sha256::new();
149 let mut buf = [0u8; 8192];
150 loop {
151 let n = reader.read(&mut buf)?;
152 if n == 0 {
153 break;
154 }
155 hasher.update(&buf[..n]);
156 }
157 Ok(format!("sha256:{:x}", hasher.finalize()))
158}
159
160#[derive(Parser)]
161#[command(name = "tbz")]
162#[command(about = "TBZ (TIBET-zip) — Block-level authenticated compression")]
163#[command(version)]
164struct Cli {
165 #[command(subcommand)]
166 command: Option<Commands>,
167
168 #[arg(global = false)]
170 path: Option<String>,
171
172 #[arg(long, global = true, default_value_t = false)]
174 mirror: bool,
175
176 #[arg(long, global = true, env = "TBZ_MIRROR_URL",
178 default_value = "https://brein.jaspervandemeent.nl")]
179 mirror_url: String,
180}
181
182#[derive(Subcommand)]
183enum Commands {
184 #[command(alias = "p")]
189 Pack {
190 path: String,
192 #[arg(short, long, default_value = "output.tza")]
194 output: String,
195 #[arg(long, default_value = "0")]
197 jis_level: u8,
198 #[arg(long)]
200 seal: bool,
201 #[arg(long, value_name = "PUBKEY_HEX")]
203 to: Option<String>,
204 #[arg(long, value_name = "PRIVKEY_PATH")]
206 from: Option<String>,
207 },
208
209 #[command(alias = "x")]
214 Unpack {
215 archive: String,
217 #[arg(short, long, default_value = ".")]
219 output: String,
220 #[arg(long = "as", value_name = "PRIVKEY_PATH")]
222 as_key: Option<String>,
223 },
224
225 #[command(alias = "v")]
227 Verify {
228 archive: String,
230 },
231
232 #[command(alias = "i")]
234 Inspect {
235 archive: String,
237 },
238
239 Init {
241 #[arg(long, default_value = "github")]
243 platform: String,
244 #[arg(long)]
246 account: Option<String>,
247 #[arg(long)]
249 repo: Option<String>,
250 },
251
252 Keygen {
256 #[arg(short, long, default_value = "tbz-key")]
258 output: String,
259 },
260}
261
262pub fn run() -> anyhow::Result<()> {
263 let _ = tracing_subscriber::fmt::try_init();
264
265 let cli = Cli::parse();
266
267 let mirror_url: Option<&str> = if cli.mirror {
269 Some(&cli.mirror_url)
270 } else {
271 None
272 };
273
274 if let Some(command) = cli.command {
276 return match command {
277 Commands::Pack { path, output, jis_level, seal, to, from } => {
278 if seal {
279 let to_hex = to.ok_or_else(|| anyhow::anyhow!(
280 "--seal requires --to <pubkey-hex> (64 hex chars)"))?;
281 cmd_pack_sealed(&path, &output, jis_level, mirror_url, &to_hex, from.as_deref())
282 } else {
283 cmd_pack(&path, &output, jis_level, mirror_url)
284 }
285 }
286 Commands::Unpack { archive, output, as_key } => {
287 cmd_unpack_dispatch(&archive, &output, as_key.as_deref())
288 }
289 Commands::Verify { archive } => cmd_verify(&archive, mirror_url),
290 Commands::Inspect { archive } => cmd_inspect(&archive),
291 Commands::Init { platform, account, repo } => cmd_init(&platform, account, repo),
292 Commands::Keygen { output } => cmd_keygen(&output),
293 };
294 }
295
296 if let Some(path) = cli.path {
310 let p = Path::new(&path);
311
312 let is_tbz_by_magic = if p.is_file() {
314 match std::fs::File::open(p) {
315 Ok(mut f) => {
316 use std::io::Read;
317 let mut buf = [0u8; 4];
318 matches!(f.read(&mut buf), Ok(n) if n == 4)
319 && buf == [0x54, 0x42, 0x5A, 0x01]
320 }
321 Err(_) => false,
322 }
323 } else {
324 false
325 };
326
327 if is_tbz_by_magic {
328 let extension_matches =
333 path.ends_with(".tza") || path.ends_with(".tbz");
334 if !extension_matches {
335 println!(
336 "✓ TBZ magic bytes detected — treating as sealed bundle"
337 );
338 println!(
339 " (filename does not carry .tza/.tbz suffix; this may be"
340 );
341 println!(
342 " an operator-renamed bundle. Content is truth, name is hint.)"
343 );
344 }
345 println!("Auto-detected: TBZ envelope → unpack (with airlock verification)\n");
346 let out_dir = p.file_stem()
347 .map(|s| s.to_string_lossy().to_string())
348 .unwrap_or_else(|| "tbz_out".to_string());
349 cmd_unpack(&path, &out_dir)?;
350 return Ok(());
351 }
352
353 if (path.ends_with(".tza") || path.ends_with(".tbz")) && p.is_file() {
354 anyhow::bail!(
358 "File '{}' has .tza/.tbz extension but does NOT carry the TBZ magic bytes. \n Refusing to treat as a sealed archive. Use `tbz inspect {}` for details.",
359 path, path
360 );
361 } else if p.is_dir() {
362 let dir_name = p.file_name()
364 .map(|s| s.to_string_lossy().to_string())
365 .unwrap_or_else(|| "output".to_string());
366 let output = format!("{}.tza", dir_name);
367 println!("Auto-detected: directory → pack to {}\n", output);
368 cmd_pack(&path, &output, 0, mirror_url)?;
369 return Ok(());
370 } else if p.is_file() {
371 let file_name = p.file_stem()
373 .map(|s| s.to_string_lossy().to_string())
374 .unwrap_or_else(|| "output".to_string());
375 let output = format!("{}.tza", file_name);
376 println!("Auto-detected: file → pack to {}\n", output);
377 cmd_pack(&path, &output, 0, mirror_url)?;
378 return Ok(());
379 } else {
380 anyhow::bail!("Path not found: {}", path);
381 }
382 }
383
384 Cli::parse_from(["tbz", "--help"]);
386 Ok(())
387}
388
389fn cmd_pack(path: &str, output: &str, default_jis_level: u8, mirror_url: Option<&str>) -> anyhow::Result<()> {
391 let source = Path::new(path);
392 if !source.exists() {
393 anyhow::bail!("Source path does not exist: {}", path);
394 }
395
396 let files = collect_files(source)?;
398 println!("TBZ pack: {} file(s) from {}", files.len(), path);
399
400 let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
402 if let Some(ref jis) = jis_manifest {
403 println!(" .jis.json found: {}", jis.repo_identifier());
404 }
405
406 let (signing_key, verifying_key) = signature::generate_keypair();
408
409 let mut manifest = Manifest::new();
411 for (i, (file_path, data)) in files.iter().enumerate() {
412 let jis_level = jis_manifest
413 .as_ref()
414 .map(|j| j.jis_level_for_path(file_path))
415 .unwrap_or(default_jis_level);
416
417 manifest.add_block(BlockEntry {
418 index: (i + 1) as u32,
419 block_type: "data".to_string(),
420 compressed_size: 0, uncompressed_size: data.len() as u64,
422 jis_level,
423 description: file_path.clone(),
424 path: Some(file_path.clone()),
425 });
426 }
427
428 manifest.set_signing_key(&verifying_key);
430
431 let out_file = fs::File::create(output)?;
433 let mut writer = TbzWriter::new(BufWriter::new(out_file), signing_key);
434
435 writer.write_manifest(&manifest)?;
437 println!(" [0] manifest ({} block entries)", manifest.blocks.len());
438
439 for (file_path, data) in &files {
441 let jis_level = jis_manifest
442 .as_ref()
443 .map(|j| j.jis_level_for_path(file_path))
444 .unwrap_or(default_jis_level);
445
446 let envelope = TibetEnvelope::new(
447 signature::sha256_hash(data),
448 "data",
449 mime_for_path(file_path),
450 "tbz-cli",
451 &format!("Pack file: {}", file_path),
452 vec!["block:0".to_string()],
453 );
454
455 let envelope = if let Some(ref jis) = jis_manifest {
456 envelope.with_source_repo(&jis.repo_identifier())
457 } else {
458 envelope
459 };
460
461 writer.write_data_block(data, jis_level, &envelope)?;
462 println!(
463 " [{}] {} ({} bytes, JIS level {})",
464 writer.block_count() - 1,
465 file_path,
466 data.len(),
467 jis_level,
468 );
469 }
470
471 let total_blocks = writer.block_count();
472 writer.finish();
473
474 let vk_hex = hex_encode(&verifying_key.to_bytes());
476
477 println!("\nArchive written: {}", output);
478 println!(" Blocks: {}", total_blocks);
479 println!(" Signing key (Ed25519 public): {}", vk_hex);
480 println!(" Format: TBZ v{}", tbz_core::VERSION);
481
482 if let Some(url) = mirror_url {
484 let archive_hash = hash_file(Path::new(output))?;
485 println!("\n Mirror: registering {} ...", archive_hash);
486
487 let jis_id = jis_manifest.as_ref().map(|_| {
488 format!("jis:ed25519:{}", &vk_hex[..16])
489 });
490 let source_repo = jis_manifest.as_ref().map(|j| j.repo_identifier());
491
492 let payload = mirror_client::RegisterPayload {
493 content_hash: archive_hash,
494 signing_key: vk_hex.clone(),
495 jis_id,
496 source_repo,
497 block_count: total_blocks as u32,
498 total_size: fs::metadata(output).map(|m| m.len()).unwrap_or(0),
499 };
500
501 match mirror_client::register(url, &payload) {
502 Ok(resp) => println!(" Mirror: {} ({})", resp.status, url),
503 Err(e) => println!(" Mirror: WARNING — {}", e),
504 }
505 }
506
507 Ok(())
508}
509
510fn cmd_inspect(archive: &str) -> anyhow::Result<()> {
512 match detect_format(archive)? {
514 ArchiveFormat::TibetZip => return tibet_zip::inspect(archive),
515 ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
516 ArchiveFormat::TbzBlock => {} }
518
519 let file = fs::File::open(archive)?;
520 let mut reader = TbzReader::new(std::io::BufReader::new(file));
521
522 println!("TBZ inspect: {}\n", archive);
523 println!(" Magic: 0x54425A (TBZ)");
524 println!(" Format: v{}\n", tbz_core::VERSION);
525
526 let mut block_idx = 0;
527 while let Some(block) = reader.read_block()? {
528 let type_str = match block.header.block_type {
529 BlockType::Manifest => "MANIFEST",
530 BlockType::Data => "DATA",
531 BlockType::Nested => "NESTED",
532 };
533
534 println!(" Block {} [{}]", block.header.block_index, type_str);
535 println!(" JIS level: {}", block.header.jis_level);
536 println!(" Compressed: {} bytes", block.header.compressed_size);
537 println!(" Uncompressed: {} bytes", block.header.uncompressed_size);
538 println!(" TIBET ERIN hash: {}", block.envelope.erin.content_hash);
539 println!(" TIBET ERACHTER: {}", block.envelope.erachter);
540
541 if let Some(ref repo) = block.envelope.eromheen.source_repo {
542 println!(" Source repo: {}", repo);
543 }
544
545 if block.header.block_type == BlockType::Manifest {
547 if let Ok(decompressed) = block.decompress() {
548 if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
549 println!(" --- Manifest ---");
550 println!(" Total blocks: {}", manifest.block_count);
551 println!(" Total uncompressed: {} bytes", manifest.total_uncompressed_size);
552 println!(" Max JIS level: {}", manifest.max_jis_level());
553 for entry in &manifest.blocks {
554 println!(
555 " [{:>3}] {} — {} bytes, JIS {}",
556 entry.index,
557 entry.path.as_deref().unwrap_or(&entry.description),
558 entry.uncompressed_size,
559 entry.jis_level,
560 );
561 }
562 }
563 }
564 }
565
566 let sig_nonzero = block.signature.iter().any(|&b| b != 0);
568 println!(" Signature: {}", if sig_nonzero { "Ed25519 (present)" } else { "none" });
569 println!();
570
571 block_idx += 1;
572 }
573
574 println!(" Total: {} blocks", block_idx);
575 Ok(())
576}
577
578fn cmd_unpack(archive: &str, output_dir: &str) -> anyhow::Result<()> {
583 match detect_format(archive)? {
585 ArchiveFormat::TibetZip => return tibet_zip::unpack(archive, output_dir),
586 ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
587 ArchiveFormat::TbzBlock => {} }
589
590 println!("TBZ unpack: {} → {}\n", archive, output_dir);
594 println!(" Airlock pre-check: verifying archive integrity...\n");
595
596 {
597 let vfile = fs::File::open(archive)?;
598 let mut vreader = TbzReader::new(std::io::BufReader::new(vfile));
599 let mut errors = 0u32;
600 let mut block_count = 0u32;
601 let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
602
603 while let Some(block) = vreader.read_block()? {
604 if let Err(_) = block.validate() {
605 errors += 1;
606 block_count += 1;
607 continue;
608 }
609
610 if block.header.block_type == BlockType::Manifest {
611 if let Ok(decompressed) = block.decompress() {
612 if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
613 verifying_key = manifest.get_verifying_key();
614 }
615 }
616 }
617
618 if let Some(ref vk) = verifying_key {
620 if block.verify_signature(vk).is_err() {
621 errors += 1;
622 }
623 }
624
625 match block.decompress() {
627 Ok(decompressed) => {
628 let actual_hash = signature::sha256_hash(&decompressed);
629 if actual_hash != block.envelope.erin.content_hash {
630 errors += 1;
631 }
632 }
633 Err(_) => { errors += 1; }
634 }
635
636 block_count += 1;
637 }
638
639 if errors > 0 {
640 anyhow::bail!(
641 "AIRLOCK BREACH BLOCKED — archive corrupt: {} ({} block errors in {} blocks). \
642 Use `tbz verify` to inspect, or fix the archive.",
643 archive, errors, block_count
644 );
645 }
646
647 println!(" Airlock pre-check: {} blocks verified ✓\n", block_count);
648 }
649
650 let file = fs::File::open(archive)?;
654 let mut reader = TbzReader::new(std::io::BufReader::new(file));
655
656 let mut airlock = tbz_airlock::Airlock::new(256 * 1024 * 1024, 30);
658 println!(" Airlock mode: {:?}\n", airlock.mode());
659
660 fs::create_dir_all(output_dir)?;
661
662 let mut block_idx = 0;
663 let mut manifest: Option<Manifest> = None;
664
665 while let Some(block) = reader.read_block()? {
666 match block.header.block_type {
667 BlockType::Manifest => {
668 let decompressed = block.decompress()?;
669 manifest = Some(serde_json::from_slice(&decompressed)
670 .map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?);
671 println!(" [0] Manifest parsed ({} entries)", manifest.as_ref().unwrap().blocks.len());
672 }
673 BlockType::Data => {
674 let decompressed = block.decompress()?;
676 airlock.allocate(decompressed.len() as u64)?;
677 airlock.receive(&decompressed)?;
678
679 let file_path = manifest
681 .as_ref()
682 .and_then(|m| {
683 m.blocks.iter()
684 .find(|e| e.index == block.header.block_index)
685 .and_then(|e| e.path.clone())
686 })
687 .unwrap_or_else(|| format!("block_{}", block.header.block_index));
688
689 let out_path = Path::new(output_dir).join(&file_path);
691 if let Some(parent) = out_path.parent() {
692 fs::create_dir_all(parent)?;
693 }
694
695 let data = airlock.release(); fs::write(&out_path, &data)?;
697
698 println!(
699 " [{}] {} ({} bytes) ✓",
700 block.header.block_index,
701 file_path,
702 data.len(),
703 );
704 }
705 BlockType::Nested => {
706 println!(" [{}] Nested TBZ (not yet supported)", block.header.block_index);
707 }
708 }
709 block_idx += 1;
710 }
711
712 println!("\n Extracted {} blocks via Airlock", block_idx);
713 println!(" Airlock buffer: wiped (0x00)");
714 Ok(())
715}
716
717fn cmd_verify(archive: &str, mirror_url: Option<&str>) -> anyhow::Result<()> {
719 match detect_format(archive)? {
721 ArchiveFormat::TibetZip => return tibet_zip::verify(archive),
722 ArchiveFormat::Unknown => anyhow::bail!("Not a TBZ archive: unrecognized format"),
723 ArchiveFormat::TbzBlock => {} }
725
726 let file = fs::File::open(archive)?;
727 let mut reader = TbzReader::new(std::io::BufReader::new(file));
728
729 println!("TBZ verify: {}\n", archive);
730
731 let mut errors = 0;
732 let mut block_idx = 0;
733 let mut verifying_key: Option<tbz_core::VerifyingKey> = None;
734
735 while let Some(block) = reader.read_block()? {
736 if let Err(e) = block.validate() {
738 println!(" [{}] FAIL header: {}", block.header.block_index, e);
739 errors += 1;
740 block_idx += 1;
741 continue;
742 }
743
744 if block.header.block_type == BlockType::Manifest {
746 if let Ok(decompressed) = block.decompress() {
747 if let Ok(manifest) = serde_json::from_slice::<Manifest>(&decompressed) {
748 verifying_key = manifest.get_verifying_key();
749 if let Some(ref vk) = verifying_key {
750 let vk_hex = hex_encode(&vk.to_bytes());
751 println!(" Signing key: Ed25519 {}", &vk_hex[..16]);
752 println!();
753 } else {
754 println!(" WARNING: No signing key in manifest — signature checks skipped\n");
755 }
756 }
757 }
758 }
759
760 let sig_ok = if let Some(ref vk) = verifying_key {
762 match block.verify_signature(vk) {
763 Ok(()) => true,
764 Err(e) => {
765 println!(" [{}] FAIL signature: {}", block.header.block_index, e);
766 errors += 1;
767 false
768 }
769 }
770 } else {
771 true };
773
774 match block.decompress() {
776 Ok(decompressed) => {
777 let actual_hash = signature::sha256_hash(&decompressed);
778 if actual_hash == block.envelope.erin.content_hash {
779 let sig_status = if verifying_key.is_some() && sig_ok {
780 "hash + signature"
781 } else if verifying_key.is_some() {
782 "hash only (sig FAILED)"
783 } else {
784 "hash only (no key)"
785 };
786 println!(" [{}] OK — {} verified", block.header.block_index, sig_status);
787 } else {
788 println!(
789 " [{}] FAIL — hash mismatch\n expected: {}\n actual: {}",
790 block.header.block_index,
791 block.envelope.erin.content_hash,
792 actual_hash,
793 );
794 errors += 1;
795 }
796 }
797 Err(e) => {
798 println!(" [{}] FAIL — decompress error: {}", block.header.block_index, e);
799 errors += 1;
800 }
801 }
802
803 block_idx += 1;
804 }
805
806 println!();
807 if errors == 0 {
808 if verifying_key.is_some() {
809 println!(" Result: ALL {} BLOCKS VERIFIED (hash + Ed25519) ✓", block_idx);
810 } else {
811 println!(" Result: ALL {} BLOCKS VERIFIED (hash only, no signing key) ✓", block_idx);
812 }
813 } else {
814 println!(" Result: {} ERRORS in {} blocks ✗", errors, block_idx);
815 }
816
817 if let Some(url) = mirror_url {
819 let archive_hash = hash_file(Path::new(archive))?;
820 match mirror_client::lookup(url, &archive_hash) {
821 Ok(Some(entry)) => {
822 let verdicts: Vec<&str> = entry.attestations.iter()
823 .map(|a| a.verdict.as_str())
824 .collect();
825 println!("\n Mirror: KNOWN");
826 println!(" Hash: {}", entry.content_hash);
827 println!(" First seen: {}", entry.first_seen);
828 println!(
829 " Attestations: {} ({})",
830 entry.attestations.len(),
831 if verdicts.is_empty() { "none".to_string() } else { verdicts.join(", ") },
832 );
833 }
834 Ok(None) => {
835 println!("\n Mirror: UNKNOWN — not registered in Transparency Mirror");
836 }
837 Err(e) => {
838 println!("\n Mirror: WARNING — {}", e);
839 }
840 }
841 }
842
843 Ok(())
844}
845
846fn cmd_init(platform: &str, account: Option<String>, repo: Option<String>) -> anyhow::Result<()> {
848 let account = account.unwrap_or_else(|| "<your-account>".to_string());
849 let repo = repo.unwrap_or_else(|| {
850 std::env::current_dir()
851 .ok()
852 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
853 .unwrap_or_else(|| "<repo>".to_string())
854 });
855
856 let tbz_dir = Path::new(".tbz");
858 let key_path = tbz_dir.join("signing.key");
859 let pub_path = tbz_dir.join("signing.pub");
860
861 let (signing_key, verifying_key) = if key_path.exists() {
862 let sk_hex = fs::read_to_string(&key_path)?;
864 let sk_bytes: Vec<u8> = (0..sk_hex.trim().len())
865 .step_by(2)
866 .filter_map(|i| u8::from_str_radix(&sk_hex.trim()[i..i + 2], 16).ok())
867 .collect();
868 if sk_bytes.len() != 32 {
869 anyhow::bail!("Invalid signing key in .tbz/signing.key");
870 }
871 let mut key_array = [0u8; 32];
872 key_array.copy_from_slice(&sk_bytes);
873 let sk = tbz_core::SigningKey::from_bytes(&key_array);
874 let vk = sk.verifying_key();
875 println!("Using existing keypair from .tbz/");
876 (sk, vk)
877 } else {
878 let (sk, vk) = signature::generate_keypair();
880
881 fs::create_dir_all(tbz_dir)?;
882 fs::write(&key_path, hex_encode(&sk.to_bytes()))?;
883 fs::write(&pub_path, hex_encode(&vk.to_bytes()))?;
884
885 println!("Generated Ed25519 keypair:");
886 println!(" Private: .tbz/signing.key (KEEP SECRET — add to .gitignore!)");
887 println!(" Public: .tbz/signing.pub");
888 (sk, vk)
889 };
890
891 let vk_hex = hex_encode(&verifying_key.to_bytes());
892 let jis_id = format!("jis:ed25519:{}", &vk_hex[..16]);
893
894 let claim_data = format!("{}:{}:{}:{}", platform, account, repo, vk_hex);
896 let claim_sig = signature::sign(claim_data.as_bytes(), &signing_key);
897
898 let jis_json = serde_json::json!({
899 "tbz": "1.0",
900 "jis_id": jis_id,
901 "signing_key": vk_hex,
902 "claim": {
903 "platform": platform,
904 "account": account,
905 "repo": repo,
906 "intent": "official_releases",
907 "sectors": {
908 "src/*": { "jis_level": 0, "description": "Public source code" },
909 "keys/*": { "jis_level": 2, "description": "Signing keys" }
910 }
911 },
912 "tibet": {
913 "erin": "Repository identity binding",
914 "eraan": [&jis_id],
915 "erachter": format!("Provenance root for TBZ packages from {}/{}", account, repo)
916 },
917 "signature": hex_encode(&claim_sig),
918 "timestamp": chrono_now()
919 });
920
921 let output = serde_json::to_string_pretty(&jis_json)?;
922 fs::write(".jis.json", &output)?;
923
924 let gitignore = Path::new(".gitignore");
926 if gitignore.exists() {
927 let content = fs::read_to_string(gitignore)?;
928 if !content.contains(".tbz/signing.key") {
929 fs::write(gitignore, format!("{}\n# TBZ signing key (NEVER commit!)\n.tbz/signing.key\n", content.trim_end()))?;
930 println!("\n Added .tbz/signing.key to .gitignore");
931 }
932 }
933
934 println!("\nGenerated .jis.json:");
935 println!(" JIS ID: {}", jis_id);
936 println!(" Claim: {}/{}/{}", platform, account, repo);
937 println!(" Signature: Ed25519 (signed)");
938
939 Ok(())
940}
941
942fn collect_files(path: &Path) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
944 let mut files = Vec::new();
945
946 if path.is_file() {
947 let data = fs::read(path)?;
948 let name = path.file_name()
949 .map(|n| n.to_string_lossy().to_string())
950 .unwrap_or_else(|| "file".to_string());
951 files.push((name, data));
952 } else if path.is_dir() {
953 collect_dir_recursive(path, path, &mut files)?;
954 }
955
956 Ok(files)
957}
958
959fn collect_dir_recursive(
960 base: &Path,
961 current: &Path,
962 files: &mut Vec<(String, Vec<u8>)>,
963) -> anyhow::Result<()> {
964 let mut entries: Vec<_> = fs::read_dir(current)?.collect::<Result<_, _>>()?;
965 entries.sort_by_key(|e| e.file_name());
966
967 for entry in entries {
968 let path = entry.path();
969 let name = entry.file_name().to_string_lossy().to_string();
971 if name.starts_with('.') || name == "target" || name == "node_modules" {
972 continue;
973 }
974
975 if path.is_file() {
976 let rel = path.strip_prefix(base)
977 .map(|p| p.to_string_lossy().to_string())
978 .unwrap_or_else(|_| name);
979 let data = fs::read(&path)?;
980 files.push((rel, data));
981 } else if path.is_dir() {
982 collect_dir_recursive(base, &path, files)?;
983 }
984 }
985 Ok(())
986}
987
988fn mime_for_path(path: &str) -> &str {
990 match path.rsplit('.').next() {
991 Some("rs") => "text/x-rust",
992 Some("toml") => "application/toml",
993 Some("json") => "application/json",
994 Some("md") => "text/markdown",
995 Some("txt") => "text/plain",
996 Some("py") => "text/x-python",
997 Some("js") => "text/javascript",
998 Some("html") => "text/html",
999 Some("css") => "text/css",
1000 Some("png") => "image/png",
1001 Some("jpg") | Some("jpeg") => "image/jpeg",
1002 Some("bin") => "application/octet-stream",
1003 _ => "application/octet-stream",
1004 }
1005}
1006
1007fn hex_encode(bytes: &[u8]) -> String {
1008 bytes.iter().map(|b| format!("{:02x}", b)).collect()
1009}
1010
1011fn hex_decode_32(s: &str) -> anyhow::Result<[u8; 32]> {
1012 let s = s.trim();
1013 if s.len() != 64 {
1014 anyhow::bail!("expected 64 hex characters, got {}", s.len());
1015 }
1016 let mut out = [0u8; 32];
1017 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
1018 let pair = std::str::from_utf8(chunk).map_err(|_| anyhow::anyhow!("invalid utf8"))?;
1019 out[i] = u8::from_str_radix(pair, 16)
1020 .map_err(|e| anyhow::anyhow!("invalid hex pair '{}': {}", pair, e))?;
1021 }
1022 Ok(out)
1023}
1024
1025fn read_signing_key_from_file(path: &str) -> anyhow::Result<ed25519_dalek::SigningKey> {
1026 let raw = fs::read_to_string(path)
1027 .map_err(|e| anyhow::anyhow!("cannot read key file {}: {}", path, e))?;
1028 let bytes = hex_decode_32(&raw)?;
1029 Ok(ed25519_dalek::SigningKey::from_bytes(&bytes))
1030}
1031
1032fn chrono_now() -> String {
1033 use std::time::SystemTime;
1034 let duration = SystemTime::now()
1035 .duration_since(SystemTime::UNIX_EPOCH)
1036 .unwrap_or_default();
1037 format!("{}Z", duration.as_secs())
1038}
1039
1040fn cmd_keygen(output: &str) -> anyhow::Result<()> {
1045 use ed25519_dalek::SigningKey;
1046 use rand::rngs::OsRng;
1047
1048 let signing_key = SigningKey::generate(&mut OsRng);
1049 let verifying_key = signing_key.verifying_key();
1050
1051 let priv_path = format!("{}.priv", output);
1052 let pub_path = format!("{}.pub", output);
1053
1054 let priv_hex = hex_encode(&signing_key.to_bytes());
1056 fs::write(&priv_path, &priv_hex)?;
1057 #[cfg(unix)]
1058 {
1059 use std::os::unix::fs::PermissionsExt;
1060 let perms = fs::Permissions::from_mode(0o600);
1061 fs::set_permissions(&priv_path, perms)?;
1062 }
1063
1064 let pub_hex = hex_encode(&verifying_key.to_bytes());
1065 fs::write(&pub_path, &pub_hex)?;
1066
1067 println!("TBZ keygen: Ed25519 keypair generated\n");
1068 println!(" Private: {} (mode 0600)", priv_path);
1069 println!(" Public: {}", pub_path);
1070 println!("\n Pubkey (share this): {}", pub_hex);
1071 println!("\n Use with:");
1072 println!(" tibet-zip pack <dir> -o sealed.tza --seal --to {} --from {}", pub_hex, priv_path);
1073 println!(" tibet-zip unpack sealed.tza -o out/ --as {}", priv_path);
1074 Ok(())
1075}
1076
1077fn cmd_pack_sealed(
1078 path: &str,
1079 output: &str,
1080 default_jis_level: u8,
1081 mirror_url: Option<&str>,
1082 receiver_hex: &str,
1083 sender_priv_path: Option<&str>,
1084) -> anyhow::Result<()> {
1085 let source = Path::new(path);
1086 if !source.exists() {
1087 anyhow::bail!("Source path does not exist: {}", path);
1088 }
1089 let receiver_pubkey = hex_decode_32(receiver_hex)
1090 .map_err(|e| anyhow::anyhow!("--to pubkey: {}", e))?;
1091
1092 let sender_key = match sender_priv_path {
1094 Some(p) => read_signing_key_from_file(p)?,
1095 None => {
1096 use rand::rngs::OsRng;
1097 ed25519_dalek::SigningKey::generate(&mut OsRng)
1098 }
1099 };
1100
1101 println!("TBZ pack (sealed v2): {} → {}", path, output);
1102 println!(" Sender pubkey: {}", hex_encode(&sender_key.verifying_key().to_bytes()));
1103 println!(" Receiver pubkey: {}", receiver_hex);
1104
1105 let v1_bytes = build_v1_archive_bytes(source, default_jis_level, mirror_url)?;
1107 println!("\n Inner v1 archive: {} bytes", v1_bytes.len());
1108
1109 let container = v2::write_sealed_container(&sender_key, &receiver_pubkey, &v1_bytes)
1111 .map_err(|e| anyhow::anyhow!("v2 seal failed: {}", e))?;
1112
1113 fs::write(output, &container)?;
1114 println!(" Outer v2 envelope: {} bytes (overhead: {} bytes)", container.len(), container.len() - v1_bytes.len());
1115 println!("\n Sealed: {} ✓", output);
1116 println!(" Format: TBZ v2 (AES-256-GCM, Ed25519-signed)");
1117 Ok(())
1118}
1119
1120fn build_v1_archive_bytes(
1123 source: &Path,
1124 default_jis_level: u8,
1125 _mirror_url: Option<&str>,
1126) -> anyhow::Result<Vec<u8>> {
1127 let files = collect_files(source)?;
1128 let jis_manifest = tbz_jis::JisManifest::load(Path::new(".")).ok();
1129
1130 let (signing_key, verifying_key) = signature::generate_keypair();
1131
1132 let mut manifest = Manifest::new();
1133 for (i, (file_path, data)) in files.iter().enumerate() {
1134 let jis_level = jis_manifest
1135 .as_ref()
1136 .map(|j| j.jis_level_for_path(file_path))
1137 .unwrap_or(default_jis_level);
1138
1139 manifest.add_block(BlockEntry {
1140 index: (i + 1) as u32,
1141 block_type: "data".to_string(),
1142 compressed_size: 0,
1143 uncompressed_size: data.len() as u64,
1144 jis_level,
1145 description: file_path.clone(),
1146 path: Some(file_path.clone()),
1147 });
1148 }
1149 manifest.set_signing_key(&verifying_key);
1150
1151 let mut buf: Vec<u8> = Vec::new();
1152 {
1153 let mut writer = TbzWriter::new(&mut buf, signing_key);
1154 writer.write_manifest(&manifest)?;
1155 for (file_path, data) in &files {
1156 let jis_level = jis_manifest
1157 .as_ref()
1158 .map(|j| j.jis_level_for_path(file_path))
1159 .unwrap_or(default_jis_level);
1160 let envelope = TibetEnvelope::new(
1161 signature::sha256_hash(data),
1162 "data",
1163 mime_for_path(file_path),
1164 "tbz-cli",
1165 &format!("Pack file: {}", file_path),
1166 vec!["block:0".to_string()],
1167 );
1168 let envelope = if let Some(ref jis) = jis_manifest {
1169 envelope.with_source_repo(&jis.repo_identifier())
1170 } else {
1171 envelope
1172 };
1173 writer.write_data_block(data, jis_level, &envelope)?;
1174 }
1175 writer.finish();
1176 }
1177 Ok(buf)
1178}
1179
1180fn cmd_unpack_dispatch(archive: &str, output_dir: &str, as_key: Option<&str>) -> anyhow::Result<()> {
1182 let mut head = [0u8; 32];
1184 let n = {
1185 let mut f = fs::File::open(archive)?;
1186 std::io::Read::read(&mut f, &mut head)?
1187 };
1188 let version = if n >= 7 { v2::detect_version(&head[..n]) } else { 0 };
1189
1190 if version == 2 {
1191 let priv_path = as_key.ok_or_else(|| anyhow::anyhow!(
1192 "{} is a v2 sealed archive — pass --as <privkey-path> to decrypt", archive))?;
1193 let receiver_key = read_signing_key_from_file(priv_path)?;
1194 println!("TBZ unpack (v2 sealed): {} → {}", archive, output_dir);
1195 let container = fs::read(archive)?;
1196 let (env, plain) = v2::read_sealed_container(&container, &receiver_key)
1197 .map_err(|e| anyhow::anyhow!("v2 unseal failed: {}", e))?;
1198 println!(" Sender: {}", hex_encode(&env.sender_pubkey));
1199 println!(" Receiver: {} ✓", hex_encode(&env.receiver_pubkey));
1200 println!(" Inner v1 archive: {} bytes\n", plain.len());
1201
1202 let tmp = std::env::temp_dir().join(format!("tbz-v2-inner-{}.tza", std::process::id()));
1204 fs::write(&tmp, &plain)?;
1205 let result = cmd_unpack(tmp.to_str().unwrap(), output_dir);
1206 let _ = fs::remove_file(&tmp);
1207 result
1208 } else {
1209 cmd_unpack(archive, output_dir)
1210 }
1211}