1use crate::crypto::{CipherId, Kek, NONCE_LEN, SecretKey};
62use crate::error::{Error, Result};
63
64const INFO_DIRNAME: &[u8] = b"omy/v1/dirname";
69
70pub const DIR_SUFFIX: &str = ".omy";
76
77pub const DIRNAME_SIDECAR: &str = ".omy-name";
82
83const MAX_COMPONENT: usize = 255;
88
89const TRUNCATE_AT: usize = 200;
95
96const HASH_SUFFIX_LEN: usize = 8;
102
103const B32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
105
106#[derive(Debug)]
111pub struct DirnameKey(SecretKey);
112
113impl DirnameKey {
114 #[must_use]
120 pub fn derive(kek: &Kek, vault_salt: &[u8; 16]) -> Self {
121 Self(kek.derive_dirname_key(vault_salt, INFO_DIRNAME))
123 }
124
125 #[must_use]
127 const fn as_key(&self) -> &SecretKey {
128 &self.0
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct EncryptedDirname {
135 pub disk_name: String,
137 pub sidecar: Option<Vec<u8>>,
142}
143
144pub fn encrypt_dirname(
153 name: &str,
154 key: &DirnameKey,
155 nonce: &[u8; NONCE_LEN],
156 cipher: CipherId,
157) -> Result<EncryptedDirname> {
158 encrypt_with_key(name, key.as_key(), nonce, cipher)
159}
160
161fn encrypt_with_key(
167 name: &str,
168 key: &SecretKey,
169 nonce: &[u8; NONCE_LEN],
170 cipher: CipherId,
171) -> Result<EncryptedDirname> {
172 if name.is_empty() {
173 return Err(Error::MalformedTlv {
174 tlv_type: 0,
175 reason: "directory name must not be empty",
176 });
177 }
178 if name.contains('/') || name.contains('\\') {
181 return Err(Error::MalformedTlv {
182 tlv_type: 0,
183 reason: "directory name must be a single component",
184 });
185 }
186
187 let ct = cipher.encrypt(key, nonce, name.as_bytes(), &[])?;
189 let mut blob = Vec::with_capacity(NONCE_LEN.saturating_add(ct.len()));
190 blob.extend_from_slice(nonce);
191 blob.extend_from_slice(&ct);
192
193 let encoded = base32_encode(&blob);
194
195 if encoded.len().saturating_add(DIR_SUFFIX.len()) <= MAX_COMPONENT {
197 return Ok(EncryptedDirname {
198 disk_name: format!("{encoded}{DIR_SUFFIX}"),
199 sidecar: None,
200 });
201 }
202
203 let digest = crate::util::blake2b_256(&blob);
206 let tail = base32_encode(digest.get(..5).unwrap_or(&digest));
207 let head = encoded.get(..TRUNCATE_AT).unwrap_or(&encoded);
208 Ok(EncryptedDirname {
209 disk_name: format!(
210 "{head}~{}{DIR_SUFFIX}",
211 tail.get(..HASH_SUFFIX_LEN).unwrap_or(&tail)
212 ),
213 sidecar: Some(blob),
214 })
215}
216
217pub fn decrypt_dirname(
228 disk_name: &str,
229 sidecar: Option<&[u8]>,
230 key: &DirnameKey,
231 cipher: CipherId,
232) -> Result<String> {
233 decrypt_with_key(disk_name, sidecar, key.as_key(), cipher)
234}
235
236fn decrypt_with_key(
238 disk_name: &str,
239 sidecar: Option<&[u8]>,
240 key: &SecretKey,
241 cipher: CipherId,
242) -> Result<String> {
243 let stem = disk_name.strip_suffix(DIR_SUFFIX).ok_or(Error::MalformedTlv {
244 tlv_type: 0,
245 reason: "not an encrypted directory name",
246 })?;
247
248 let blob = if stem.contains('~') {
252 sidecar
253 .ok_or(Error::MalformedTlv {
254 tlv_type: 0,
255 reason: "truncated directory name requires sidecar file",
256 })?
257 .to_vec()
258 } else {
259 base32_decode(stem)?
260 };
261
262 let (nonce, ct) = blob.split_at_checked(NONCE_LEN).ok_or(Error::MalformedTlv {
263 tlv_type: 0,
264 reason: "encrypted directory name too short to contain a nonce",
265 })?;
266 let mut n = [0u8; NONCE_LEN];
267 n.copy_from_slice(nonce);
268
269 let plain = cipher
270 .decrypt(key, &n, ct, &[])?
271 .ok_or(Error::ContentHashMismatch)?;
275
276 String::from_utf8(plain).map_err(|_| Error::MalformedTlv {
277 tlv_type: 0,
278 reason: "decrypted directory name is not valid UTF-8",
279 })
280}
281
282pub fn encrypt_dirname_with(
297 name: &str,
298 dk: &crate::dirsidecar::DirKey,
299 nonce: &[u8; NONCE_LEN],
300 cipher: CipherId,
301) -> Result<EncryptedDirname> {
302 encrypt_with_key(name, dk.as_key(), nonce, cipher)
303}
304
305pub fn decrypt_dirname_with(
314 disk_name: &str,
315 long_name: Option<&[u8]>,
316 dk: &crate::dirsidecar::DirKey,
317 cipher: CipherId,
318) -> Result<String> {
319 decrypt_with_key(disk_name, long_name, dk.as_key(), cipher)
320}
321
322#[must_use]
327pub fn looks_encrypted(disk_name: &str) -> bool {
328 let Some(stem) = disk_name.strip_suffix(DIR_SUFFIX) else {
329 return false;
330 };
331 if stem.is_empty() {
332 return false;
333 }
334 stem.bytes()
336 .all(|b| b == b'~' || B32.contains(&b))
337}
338
339fn base32_encode(data: &[u8]) -> String {
343 let mut out = String::with_capacity(data.len().div_ceil(5).saturating_mul(8));
344 for chunk in data.chunks(5) {
346 let mut acc = 0u64;
347 for (i, &b) in chunk.iter().enumerate() {
348 let shift = 32usize.saturating_sub(i.saturating_mul(8));
350 acc |= u64::from(b) << shift;
351 }
352 let chars = chunk.len().saturating_mul(8).div_ceil(5);
354 for i in 0..chars {
355 let shift = 35usize.saturating_sub(i.saturating_mul(5));
356 let idx = ((acc >> shift) & 0x1F) as usize;
357 out.push(char::from(*B32.get(idx).unwrap_or(&b'A')));
358 }
359 }
360 out
361}
362
363fn base32_decode(s: &str) -> Result<Vec<u8>> {
369 let mut out = Vec::with_capacity(s.len().saturating_mul(5).div_ceil(8));
373 let mut acc = 0u64;
374 let mut bits = 0u32;
375 for b in s.bytes() {
376 let Some(v) = B32.iter().position(|&c| c == b) else {
377 return Err(Error::MalformedTlv {
378 tlv_type: 0,
379 reason: "invalid base32 character in directory name",
380 });
381 };
382 acc = (acc << 5) | v as u64;
383 bits = bits.saturating_add(5);
384 if bits >= 8 {
385 bits = bits.saturating_sub(8);
386 let byte = ((acc >> bits) & 0xFF) as u8;
388 out.push(byte);
389 }
390 }
391 if bits > 0 && (acc & ((1u64 << bits).saturating_sub(1))) != 0 {
394 return Err(Error::MalformedTlv {
395 tlv_type: 0,
396 reason: "non-zero base32 padding bits",
397 });
398 }
399 Ok(out)
400}
401
402#[cfg(test)]
403#[expect(
404 clippy::unwrap_used,
405 clippy::indexing_slicing,
406 clippy::panic,
407 reason = "测试里用断言即可"
408)]
409mod tests {
410 use super::*;
411 use crate::crypto::Argon2Params;
412
413 fn test_key() -> DirnameKey {
414 let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
415 DirnameKey::derive(&kek, &[7u8; 16])
416 }
417
418 fn nonce(seed: u8) -> [u8; NONCE_LEN] {
419 core::array::from_fn(|i| (i as u8).wrapping_mul(3) ^ seed)
420 }
421
422 #[test]
423 fn roundtrips_ascii_and_chinese() {
424 let k = test_key();
425 for name in ["docs", "工作资料", "a b c", "photos.2024"] {
426 let e = encrypt_dirname(name, &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
427 assert!(e.sidecar.is_none(), "{name} 不该需要边车文件");
428 let back =
429 decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
430 assert_eq!(back, name);
431 }
432 }
433
434 #[test]
435 fn encoded_name_has_no_slash_and_is_uppercase() {
436 let k = test_key();
439 let e = encrypt_dirname("工作资料", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
440 let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
441 assert!(!stem.contains('/'), "不能含路径分隔符");
442 assert!(!stem.contains('\\'), "不能含反斜杠");
443 assert!(
444 stem.bytes().all(|b| B32.contains(&b)),
445 "只能出现 base32 字母表内的字符,实际 {stem}"
446 );
447 }
448
449 #[test]
450 fn case_insensitive_filesystems_cannot_collide() {
451 let k = test_key();
454 for seed in 0..40u8 {
455 let e = encrypt_dirname("x", &k, &nonce(seed), CipherId::ChaCha20Poly1305).unwrap();
456 let stem = e.disk_name.strip_suffix(DIR_SUFFIX).unwrap();
457 assert_eq!(
458 stem,
459 stem.to_uppercase(),
460 "第 {seed} 个密文名含小写字母,在 Windows/APFS 上会与其它名字折叠"
461 );
462 }
463 }
464
465 #[test]
466 fn different_nonce_yields_different_ciphertext() {
467 let k = test_key();
469 let a = encrypt_dirname("同一个名字", &k, &nonce(1), CipherId::ChaCha20Poly1305).unwrap();
470 let b = encrypt_dirname("同一个名字", &k, &nonce(2), CipherId::ChaCha20Poly1305).unwrap();
471 assert_ne!(a.disk_name, b.disk_name, "相同明文不该产出相同密文名");
472 for e in [a, b] {
474 let back =
475 decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap();
476 assert_eq!(back, "同一个名字");
477 }
478 }
479
480 #[test]
481 fn long_name_is_truncated_with_sidecar() {
482 let k = test_key();
485 let long = "很长的目录名".repeat(40); let e = encrypt_dirname(&long, &k, &nonce(3), CipherId::ChaCha20Poly1305).unwrap();
487
488 assert!(
489 e.disk_name.len() <= MAX_COMPONENT,
490 "截断后必须落在文件系统上限内,实际 {} 字节",
491 e.disk_name.len()
492 );
493 assert!(e.disk_name.contains('~'), "截断名要有 ~ 标记");
494 let sc = e.sidecar.as_deref().expect("截断时必须给出边车内容");
495
496 let back = decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
497 assert_eq!(back, long, "靠边车文件要能完整还原");
498 }
499
500 #[test]
501 fn truncated_names_of_same_plaintext_still_differ() {
502 let k = test_key();
509 let long = "同一个很长的目录名".repeat(30);
510 let a = encrypt_dirname(&long, &k, &nonce(11), CipherId::ChaCha20Poly1305).unwrap();
511 let b = encrypt_dirname(&long, &k, &nonce(12), CipherId::ChaCha20Poly1305).unwrap();
512
513 assert!(a.disk_name.contains('~') && b.disk_name.contains('~'), "两者都该被截断");
514 assert_ne!(a.disk_name, b.disk_name, "同名长目录不该产出相同的磁盘名");
515
516 let suffix = |s: &str| -> String {
519 s.rsplit_once('~').map(|(_, t)| t.to_owned()).unwrap_or_default()
520 };
521 assert_ne!(
522 suffix(&a.disk_name),
523 suffix(&b.disk_name),
524 "哈希后缀相同就泄露了「这两个目录同名」"
525 );
526
527 for e in [a, b] {
529 let sc = e.sidecar.as_deref().unwrap();
530 let back =
531 decrypt_dirname(&e.disk_name, Some(sc), &k, CipherId::ChaCha20Poly1305).unwrap();
532 assert_eq!(back, long);
533 }
534 }
535
536 #[test]
537 fn truncated_name_without_sidecar_reports_clearly() {
538 let k = test_key();
541 let long = "长".repeat(200);
542 let e = encrypt_dirname(&long, &k, &nonce(4), CipherId::ChaCha20Poly1305).unwrap();
543 let err = decrypt_dirname(&e.disk_name, None, &k, CipherId::ChaCha20Poly1305).unwrap_err();
544 match err {
545 Error::MalformedTlv { reason, .. } => {
546 assert!(reason.contains("sidecar"), "错误应指明缺边车文件,实际 {reason}");
547 }
548 other => panic!("应报缺边车文件,实际 {other:?}"),
549 }
550 }
551
552 #[test]
553 fn wrong_key_fails_authentication() {
554 let k = test_key();
555 let e = encrypt_dirname("secret", &k, &nonce(5), CipherId::ChaCha20Poly1305).unwrap();
556
557 let other_kek =
558 Kek::from_password(b"different", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
559 let other = DirnameKey::derive(&other_kek, &[7u8; 16]);
560 assert!(
561 decrypt_dirname(&e.disk_name, None, &other, CipherId::ChaCha20Poly1305).is_err(),
562 "错误密钥必须解不开"
563 );
564 }
565
566 #[test]
567 fn different_vault_salt_yields_different_key() {
568 let kek = Kek::from_password(b"pw", &[7u8; 16], Argon2Params::TEST_WEAK).unwrap();
570 let a = DirnameKey::derive(&kek, &[7u8; 16]);
571 let b = DirnameKey::derive(&kek, &[9u8; 16]);
572 let e = encrypt_dirname("x", &a, &nonce(6), CipherId::ChaCha20Poly1305).unwrap();
573 assert!(
574 decrypt_dirname(&e.disk_name, None, &b, CipherId::ChaCha20Poly1305).is_err(),
575 "换了 vault_salt 就不该解得开"
576 );
577 }
578
579 #[test]
580 fn rejects_path_components() {
581 let k = test_key();
584 for bad in ["a/b", "a\\b", ""] {
585 assert!(
586 encrypt_dirname(bad, &k, &nonce(7), CipherId::ChaCha20Poly1305).is_err(),
587 "{bad:?} 必须被拒绝"
588 );
589 }
590 }
591
592 #[test]
593 fn base32_matches_rfc4648_vectors() {
594 assert_eq!(base32_encode(b""), "");
597 assert_eq!(base32_encode(b"f"), "MY");
598 assert_eq!(base32_encode(b"fo"), "MZXQ");
599 assert_eq!(base32_encode(b"foo"), "MZXW6");
600 assert_eq!(base32_encode(b"foob"), "MZXW6YQ");
601 assert_eq!(base32_encode(b"fooba"), "MZXW6YTB");
602 assert_eq!(base32_encode(b"foobar"), "MZXW6YTBOI");
603 }
604
605 #[test]
606 fn base32_matches_python_stdlib_at_length_boundaries() {
607 let data: Vec<u8> = (0..256u32).map(|i| ((i * 11) ^ 0x5B) as u8).collect();
615 let expected: &[(usize, &str)] = &[
616 (1, "LM"),
617 (2, "LNIA"),
618 (3, "LNIE2"),
619 (4, "LNIE26Q"),
620 (5, "LNIE26TX"),
621 (6, "LNIE26TXNQ"),
622 (7, "LNIE26TXNQMQ"),
623 (8, "LNIE26TXNQMRM"),
624 (31, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECE"),
625 (32, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHA"),
626 (33, "LNIE26TXNQMRMAZYGURN7VGB73V6BHMKQ66KTJSTJBCXE33ECEHDW"),
627 ];
628 for &(len, want) in expected {
629 assert_eq!(
630 base32_encode(&data[..len]),
631 want,
632 "长度 {len}(mod 5 = {})与 Python 标准库不一致",
633 len % 5
634 );
635 }
636
637 for len in 1..=64usize {
641 let want_chars = (len * 8).div_ceil(5);
642 assert_eq!(
643 base32_encode(&data[..len]).len(),
644 want_chars,
645 "长度 {len} 应产出 {want_chars} 个字符"
646 );
647 }
648 }
649
650 #[test]
651 fn base32_roundtrips_arbitrary_bytes() {
652 let data: Vec<u8> = (0..=255u8).map(|i| i.wrapping_mul(11) ^ 0x5B).collect();
654 for len in [1usize, 2, 3, 4, 5, 6, 7, 8, 31, 32, 33, 255] {
655 let slice = &data[..len];
656 let enc = base32_encode(slice);
657 let dec = base32_decode(&enc).unwrap();
658 assert_eq!(dec, slice, "长度 {len} 往返失败");
659 }
660 }
661
662 #[test]
663 fn base32_rejects_invalid_chars() {
664 for bad in ["mzxw", "MZXW0", "MZXW1", "MZXW8", "MZ=XW"] {
666 assert!(base32_decode(bad).is_err(), "{bad} 应被拒绝");
667 }
668 }
669
670 #[test]
671 fn base32_rejects_nonzero_padding_bits() {
672 assert!(base32_decode("MY").is_ok(), "MY 的填充位是 0");
675 assert!(base32_decode("MZ").is_err(), "非零填充位必须被拒绝");
677 }
678
679 #[test]
680 fn looks_encrypted_is_precise() {
681 let k = test_key();
682 let e = encrypt_dirname("x", &k, &nonce(8), CipherId::ChaCha20Poly1305).unwrap();
683 assert!(looks_encrypted(&e.disk_name));
684
685 for plain in ["docs", "工作资料.omy", "my-stuff.omy", ".omy", "docs.txt"] {
688 assert!(!looks_encrypted(plain), "{plain} 不该被判为密文目录名");
689 }
690 }
691}