1use crate::hash::{HASH_LEN, Hash};
24use crate::object::{Commit, Identity, MAGIC, MkitError, ObjectType, Remix, SCHEMA_VERSION, Tag};
25
26use core::fmt;
27use std::path::Path;
28
29use ed25519_dalek::{
30 PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH, Signature as DalekSignature, Signer,
31 SigningKey, VerifyingKey,
32};
33use subtle::ConstantTimeEq;
34use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
35
36#[cfg(unix)]
38#[must_use]
39pub fn effective_uid() -> u32 {
40 #[allow(unsafe_code)]
43 unsafe {
44 libc::geteuid()
45 }
46}
47
48pub const COMMIT_DOMAIN: &[u8] = b"mkit.commit\x00";
51
52pub const REMIX_DOMAIN: &[u8] = b"mkit.remix\x00";
55
56pub const TAG_DOMAIN: &[u8] = b"mkit.tag\x00";
63
64#[derive(Clone, Copy, PartialEq, Eq, Hash)]
66pub struct PublicKey(pub [u8; PUBLIC_KEY_LENGTH]);
67
68impl fmt::Debug for PublicKey {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_tuple("PublicKey").field(&"…").finish()
71 }
72}
73
74#[derive(Clone, Zeroize, ZeroizeOnDrop)]
82pub struct SecretSeed(pub [u8; SECRET_KEY_LENGTH]);
83
84impl fmt::Debug for SecretSeed {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.debug_tuple("SecretSeed").field(&"<redacted>").finish()
87 }
88}
89
90impl PartialEq for SecretSeed {
91 fn eq(&self, other: &Self) -> bool {
96 bool::from(self.0.ct_eq(&other.0))
97 }
98}
99impl Eq for SecretSeed {}
100
101#[derive(Clone, Copy, PartialEq, Eq, Hash)]
103pub struct Signature(pub [u8; SIGNATURE_LENGTH]);
104
105impl fmt::Debug for Signature {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.debug_tuple("Signature").field(&"…").finish()
108 }
109}
110
111#[derive(Debug, PartialEq, Eq)]
113pub struct KeyPair {
114 pub public: PublicKey,
115 pub secret: SecretSeed,
116}
117
118impl KeyPair {
119 pub fn generate() -> Result<Self, MkitError> {
128 let mut seed: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
129 getrandom::fill(seed.as_mut_slice()).map_err(|_| MkitError::RngFailure)?;
130 Ok(Self::from_seed_zeroizing(&seed))
131 }
132
133 #[must_use]
171 pub fn from_seed(mut seed: [u8; SECRET_KEY_LENGTH]) -> Self {
172 let signing = SigningKey::from_bytes(&seed);
173 let public = PublicKey(signing.verifying_key().to_bytes());
174 let secret = SecretSeed(seed);
179 seed.zeroize();
180 Self { public, secret }
181 }
182
183 #[must_use]
196 pub fn from_seed_zeroizing(seed: &Zeroizing<[u8; SECRET_KEY_LENGTH]>) -> Self {
197 let signing = SigningKey::from_bytes(seed);
198 let public = PublicKey(signing.verifying_key().to_bytes());
199 let mut secret_bytes = [0u8; SECRET_KEY_LENGTH];
204 secret_bytes.copy_from_slice(seed.as_slice());
205 let secret = SecretSeed(secret_bytes);
206 secret_bytes.zeroize();
210 Self { public, secret }
211 }
212
213 #[must_use]
217 pub fn sign(&self, domain: &[u8], signing_bytes: &[u8]) -> Signature {
218 let digest = domain_digest(domain, signing_bytes);
219 let signing = SigningKey::from_bytes(&self.secret.0);
220 let sig = signing.sign(&digest);
221 Signature(sig.to_bytes())
222 }
223}
224
225pub fn verify(
244 public: &PublicKey,
245 domain: &[u8],
246 signing_bytes: &[u8],
247 sig: &Signature,
248) -> Result<(), MkitError> {
249 let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
250 let dalek_sig = DalekSignature::from_bytes(&sig.0);
251 let digest = domain_digest(domain, signing_bytes);
252 vk.verify_strict(&digest, &dalek_sig)
253 .map_err(|_| MkitError::SignatureInvalid)
254}
255
256#[must_use]
287fn domain_digest(domain: &[u8], signing_bytes: &[u8]) -> [u8; HASH_LEN] {
288 crate::hash::domain_digest(domain, signing_bytes)
289}
290
291pub fn commit_signing_hash(c: &Commit) -> Result<Hash, MkitError> {
294 let sb = commit_signing_bytes(c)?;
295 Ok(domain_digest(COMMIT_DOMAIN, &sb))
296}
297
298pub fn remix_signing_hash(r: &Remix) -> Result<Hash, MkitError> {
301 let sb = remix_signing_bytes(r)?;
302 Ok(domain_digest(REMIX_DOMAIN, &sb))
303}
304
305pub fn tag_signing_hash(t: &Tag) -> Result<Hash, MkitError> {
308 let sb = tag_signing_bytes(t)?;
309 Ok(domain_digest(TAG_DOMAIN, &sb))
310}
311
312fn write_prologue(buf: &mut Vec<u8>, t: ObjectType) {
313 buf.push(t as u8);
314 buf.extend_from_slice(&MAGIC);
315 buf.push(SCHEMA_VERSION);
316}
317
318fn write_identity(buf: &mut Vec<u8>, id: &Identity) -> Result<(), MkitError> {
319 if !id.is_valid() {
320 return Err(MkitError::InvalidIdentity);
321 }
322 buf.push(id.kind as u8);
323 let len = u16::try_from(id.bytes.len()).map_err(|_| MkitError::IdentityTooLarge)?;
324 buf.extend_from_slice(&len.to_le_bytes());
325 buf.extend_from_slice(&id.bytes);
326 Ok(())
327}
328
329pub fn commit_signing_bytes(c: &Commit) -> Result<Vec<u8>, MkitError> {
342 let mut buf = Vec::with_capacity(
343 6 + 32 + 4 + c.parents.len() * 32 + 3 + c.author.bytes.len() + 4 + c.message.len() + 8 + 32,
344 );
345 write_prologue(&mut buf, ObjectType::Commit);
346 buf.extend_from_slice(&c.tree_hash);
347 let parent_count = u32::try_from(c.parents.len()).map_err(|_| MkitError::TooManyParents)?;
348 buf.extend_from_slice(&parent_count.to_le_bytes());
349 for p in &c.parents {
350 buf.extend_from_slice(p);
351 }
352 write_identity(&mut buf, &c.author)?;
353 let mlen = u32::try_from(c.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
354 buf.extend_from_slice(&mlen.to_le_bytes());
355 buf.extend_from_slice(&c.message);
356 buf.extend_from_slice(&c.timestamp.to_le_bytes());
357 buf.extend_from_slice(&c.signer);
358 Ok(buf)
359}
360
361pub fn remix_signing_bytes(r: &Remix) -> Result<Vec<u8>, MkitError> {
364 let mut buf = Vec::with_capacity(
365 6 + 32
366 + 4
367 + r.parents.len() * 32
368 + 4
369 + r.sources.len() * 64
370 + 3
371 + r.author.bytes.len()
372 + 4
373 + r.message.len()
374 + 8
375 + 32,
376 );
377 write_prologue(&mut buf, ObjectType::Remix);
378 buf.extend_from_slice(&r.tree_hash);
379 let parent_count = u32::try_from(r.parents.len()).map_err(|_| MkitError::TooManyParents)?;
380 buf.extend_from_slice(&parent_count.to_le_bytes());
381 for p in &r.parents {
382 buf.extend_from_slice(p);
383 }
384 let source_count = u32::try_from(r.sources.len()).map_err(|_| MkitError::TooManySources)?;
385 buf.extend_from_slice(&source_count.to_le_bytes());
386 for s in &r.sources {
387 buf.extend_from_slice(&s.upstream_id);
388 buf.extend_from_slice(&s.commit_hash);
389 }
390 write_identity(&mut buf, &r.author)?;
391 let mlen = u32::try_from(r.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
392 buf.extend_from_slice(&mlen.to_le_bytes());
393 buf.extend_from_slice(&r.message);
394 buf.extend_from_slice(&r.timestamp.to_le_bytes());
395 buf.extend_from_slice(&r.signer);
396 Ok(buf)
397}
398
399pub fn tag_signing_bytes(t: &Tag) -> Result<Vec<u8>, MkitError> {
412 if !t.name_is_valid() {
413 return Err(MkitError::TagNameInvalid);
414 }
415 if matches!(t.target_type, ObjectType::Delta) {
416 return Err(MkitError::TagTargetTypeInvalid(t.target_type as u8));
417 }
418 let mut buf = Vec::with_capacity(
419 6 + 32 + 1 + 4 + t.name.len() + 3 + t.tagger.bytes.len() + 4 + t.message.len() + 8 + 32,
420 );
421 write_prologue(&mut buf, ObjectType::Tag);
422 buf.extend_from_slice(&t.target);
423 buf.push(t.target_type as u8);
424 let nlen = u32::try_from(t.name.len()).map_err(|_| MkitError::TagNameInvalid)?;
425 buf.extend_from_slice(&nlen.to_le_bytes());
426 buf.extend_from_slice(&t.name);
427 write_identity(&mut buf, &t.tagger)?;
428 let mlen = u32::try_from(t.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
429 buf.extend_from_slice(&mlen.to_le_bytes());
430 buf.extend_from_slice(&t.message);
431 buf.extend_from_slice(&t.timestamp.to_le_bytes());
432 buf.extend_from_slice(&t.signer);
433 Ok(buf)
434}
435
436pub fn sign_tag(t: &Tag, kp: &KeyPair) -> Result<Signature, MkitError> {
438 let sb = tag_signing_bytes(t)?;
439 Ok(kp.sign(TAG_DOMAIN, &sb))
440}
441
442pub fn verify_tag(t: &Tag) -> Result<(), MkitError> {
444 let sb = tag_signing_bytes(t)?;
445 let pk = PublicKey(t.signer);
446 let sig = Signature(t.signature);
447 verify(&pk, TAG_DOMAIN, &sb, &sig)
448}
449
450pub fn sign_commit(c: &Commit, kp: &KeyPair) -> Result<Signature, MkitError> {
452 let sb = commit_signing_bytes(c)?;
453 Ok(kp.sign(COMMIT_DOMAIN, &sb))
454}
455
456pub fn sign_remix(r: &Remix, kp: &KeyPair) -> Result<Signature, MkitError> {
458 let sb = remix_signing_bytes(r)?;
459 Ok(kp.sign(REMIX_DOMAIN, &sb))
460}
461
462pub fn verify_commit(c: &Commit) -> Result<(), MkitError> {
468 let sb = commit_signing_bytes(c)?;
469 let pk = PublicKey(c.signer);
470 let sig = Signature(c.signature);
471 verify(&pk, COMMIT_DOMAIN, &sb, &sig)
472}
473
474pub fn verify_remix(r: &Remix) -> Result<(), MkitError> {
476 let sb = remix_signing_bytes(r)?;
477 let pk = PublicKey(r.signer);
478 let sig = Signature(r.signature);
479 verify(&pk, REMIX_DOMAIN, &sb, &sig)
480}
481
482pub fn load_key(path: &Path) -> Result<KeyPair, MkitError> {
501 let seed = load_raw_32(path)?;
502 Ok(KeyPair::from_seed_zeroizing(&seed))
505}
506
507pub fn load_raw_32(path: &Path) -> Result<zeroize::Zeroizing<[u8; 32]>, MkitError> {
513 #[cfg(unix)]
514 {
515 use std::io::Read as _;
516 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
517 ensure_no_symlink_ancestors(path)?;
518 let mut f = std::fs::OpenOptions::new()
519 .read(true)
520 .custom_flags(libc::O_NOFOLLOW)
521 .open(path)
522 .map_err(|e| {
523 if e.raw_os_error() == Some(libc::ELOOP) {
524 MkitError::KeyPathIsSymlink(path.display().to_string())
525 } else {
526 MkitError::KeyIo(format!("open: {e}"))
527 }
528 })?;
529
530 let meta = f
534 .metadata()
535 .map_err(|e| MkitError::KeyIo(format!("fstat: {e}")))?;
536
537 let mode = meta.mode() & 0o777;
538 if mode & 0o077 != 0 {
539 return Err(MkitError::InsecureKeyPermissions { actual: mode });
540 }
541
542 let euid = effective_uid();
549 if meta.uid() != euid {
550 return Err(MkitError::InsecureKeyOwner {
551 actual: meta.uid(),
552 euid,
553 });
554 }
555
556 if let Some(parent) = path.parent()
561 && !parent.as_os_str().is_empty()
562 {
563 check_parent_dir_secure(parent)?;
564 }
565
566 let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
567 if let Err(e) = f.read_exact(seed.as_mut_slice()) {
568 return if e.kind() == std::io::ErrorKind::UnexpectedEof {
569 Err(MkitError::InvalidKeyLength {
570 actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
571 })
572 } else {
573 Err(MkitError::KeyIo(format!("read: {e}")))
574 };
575 }
576 let mut probe = [0u8; 1];
580 let trailing = f
581 .read(&mut probe)
582 .map_err(|e| MkitError::KeyIo(format!("read trailing byte: {e}")))?;
583 if trailing != 0 {
584 return Err(MkitError::InvalidKeyLength {
585 actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
586 });
587 }
588 Ok(seed)
589 }
590 #[cfg(not(unix))]
591 {
592 let raw = std::fs::read(path).map_err(|e| MkitError::KeyIo(format!("read: {e}")))?;
593 if raw.len() != SECRET_KEY_LENGTH {
594 return Err(MkitError::InvalidKeyLength { actual: raw.len() });
595 }
596 let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
597 seed.copy_from_slice(&raw);
598 let mut raw = raw;
599 raw.zeroize();
600 Ok(seed)
601 }
602}
603
604#[cfg(unix)]
605fn check_parent_dir_secure(parent: &Path) -> Result<(), MkitError> {
606 use std::os::unix::fs::MetadataExt;
607 let Ok(meta) = std::fs::metadata(parent) else {
610 return Ok(());
611 };
612 let mode = meta.mode() & 0o777;
613 if mode & 0o077 != 0 {
614 return Err(MkitError::InsecureKeyDir { actual: mode });
615 }
616 Ok(())
617}
618
619#[cfg(unix)]
620fn ensure_no_symlink_ancestors(path: &Path) -> Result<(), MkitError> {
621 let mut current = path.parent();
622 for _ in 0..3 {
623 let Some(dir) = current else {
624 break;
625 };
626 if dir.as_os_str().is_empty() {
627 break;
628 }
629 match std::fs::symlink_metadata(dir) {
630 Ok(meta) if meta.file_type().is_symlink() => {
631 return Err(MkitError::KeyPathIsSymlink(dir.display().to_string()));
632 }
633 Ok(_) => {}
634 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
635 Err(e) => return Err(MkitError::KeyIo(format!("lstat {}: {e}", dir.display()))),
636 }
637 current = dir.parent();
638 }
639 if let Ok(meta) = std::fs::symlink_metadata(path)
640 && meta.file_type().is_symlink()
641 {
642 return Err(MkitError::KeyPathIsSymlink(path.display().to_string()));
643 }
644 Ok(())
645}
646
647#[cfg(unix)]
648fn create_secure_dir_all(parent: &Path) -> Result<(), MkitError> {
649 use std::os::unix::fs::PermissionsExt;
650
651 ensure_no_symlink_ancestors(parent)?;
652 std::fs::create_dir_all(parent)
653 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
654 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
655 .map_err(|e| MkitError::KeyIo(format!("chmod parent: {e}")))?;
656 Ok(())
657}
658
659pub fn save_key(path: &Path, kp: &KeyPair) -> Result<(), MkitError> {
676 save_raw_32(path, &kp.secret.0)
677}
678
679pub fn save_raw_32(path: &Path, secret: &[u8; 32]) -> Result<(), MkitError> {
681 let parent: &Path = match path.parent() {
682 Some(p) if !p.as_os_str().is_empty() => p,
683 _ => Path::new("."),
684 };
685
686 #[cfg(unix)]
687 {
688 use std::io::Write as _;
689 use std::os::unix::fs::OpenOptionsExt;
690 create_secure_dir_all(parent)?;
691
692 let filename = path
693 .file_name()
694 .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
695 let tmp_name = {
699 let mut s = std::ffi::OsString::from(".");
700 s.push(filename);
701 s.push(format!(".tmp.{}", std::process::id()));
702 s
703 };
704 let tmp_path = parent.join(&tmp_name);
705
706 let mut f = std::fs::OpenOptions::new()
708 .write(true)
709 .create_new(true)
710 .custom_flags(libc::O_NOFOLLOW)
711 .mode(0o600)
712 .open(&tmp_path)
713 .map_err(|e| MkitError::KeyIo(format!("open tmp {}: {e}", tmp_path.display())))?;
714 if let Err(e) = f.write_all(secret) {
715 let _ = std::fs::remove_file(&tmp_path);
716 return Err(MkitError::KeyIo(format!("write: {e}")));
717 }
718 if let Err(e) = f.sync_all() {
719 let _ = std::fs::remove_file(&tmp_path);
720 return Err(MkitError::KeyIo(format!("fsync tmp: {e}")));
721 }
722 drop(f);
725
726 if let Err(e) = std::fs::rename(&tmp_path, path) {
727 let _ = std::fs::remove_file(&tmp_path);
728 return Err(MkitError::KeyIo(format!("rename: {e}")));
729 }
730
731 let dir = std::fs::File::open(parent)
735 .map_err(|e| MkitError::KeyIo(format!("open dir for fsync: {e}")))?;
736 dir.sync_all()
737 .map_err(|e| MkitError::KeyIo(format!("fsync dir: {e}")))?;
738 }
739 #[cfg(not(unix))]
740 {
741 std::fs::create_dir_all(parent)
742 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
743 let filename = path
747 .file_name()
748 .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
749 let mut tmp_name = std::ffi::OsString::from(".");
750 tmp_name.push(filename);
751 tmp_name.push(format!(".tmp.{}", std::process::id()));
752 let tmp_path = parent.join(&tmp_name);
753 std::fs::write(&tmp_path, secret)
754 .map_err(|e| MkitError::KeyIo(format!("write tmp: {e}")))?;
755 if let Err(e) = std::fs::rename(&tmp_path, path) {
756 let _ = std::fs::remove_file(&tmp_path);
757 return Err(MkitError::KeyIo(format!("rename: {e}")));
758 }
759 }
760 Ok(())
761}
762
763pub fn save_raw_32_create_new(path: &Path, secret: &[u8; 32]) -> Result<bool, MkitError> {
769 let parent: &Path = match path.parent() {
770 Some(p) if !p.as_os_str().is_empty() => p,
771 _ => Path::new("."),
772 };
773
774 #[cfg(unix)]
775 create_secure_dir_all(parent)?;
776 #[cfg(not(unix))]
777 std::fs::create_dir_all(parent)
778 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
779
780 crate::atomic::write_create_new(path, secret, false)
781 .map_err(|e| MkitError::KeyIo(format!("create key: {e}")))
782}
783
784#[cfg(test)]
789mod tests {
790 use super::*;
791 use crate::hash::{ZERO, hash};
792 use crate::object::{Identity, IdentityKind, ObjectType, RemixSource, Tag};
793
794 fn fixed_kp() -> KeyPair {
795 KeyPair::from_seed([0x42; 32])
796 }
797
798 fn ed25519_id(pk: [u8; 32]) -> Identity {
799 Identity {
800 kind: IdentityKind::Ed25519,
801 bytes: pk.to_vec(),
802 }
803 }
804
805 #[test]
810 fn sign_verify_roundtrip() {
811 let kp = fixed_kp();
812 let bytes = b"some signing bytes";
813 let sig = kp.sign(COMMIT_DOMAIN, bytes);
814 verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("verify ok");
815 }
816
817 #[test]
818 fn verify_rejects_tampered_input() {
819 let kp = fixed_kp();
820 let bytes = b"original".to_vec();
821 let sig = kp.sign(COMMIT_DOMAIN, &bytes);
822 let mut tampered = bytes.clone();
823 tampered[0] ^= 0x01;
824 assert!(matches!(
825 verify(&kp.public, COMMIT_DOMAIN, &tampered, &sig),
826 Err(MkitError::SignatureInvalid)
827 ));
828 }
829
830 #[test]
831 fn verify_rejects_wrong_key() {
832 let kp1 = fixed_kp();
833 let kp2 = KeyPair::from_seed([0x55; 32]);
834 let bytes = b"x";
835 let sig = kp1.sign(COMMIT_DOMAIN, bytes);
836 assert!(matches!(
837 verify(&kp2.public, COMMIT_DOMAIN, bytes, &sig),
838 Err(MkitError::SignatureInvalid)
839 ));
840 }
841
842 #[test]
851 fn our_signatures_pass_strict_verify() {
852 let kp = fixed_kp();
853 for (i, input) in [
858 b"" as &[u8],
859 b"a",
860 b"00000000000000000000000000000000",
861 &[0xff; 64],
862 &(0u8..=255).collect::<Vec<u8>>(),
863 ]
864 .iter()
865 .enumerate()
866 {
867 let sig = kp.sign(COMMIT_DOMAIN, input);
868 verify(&kp.public, COMMIT_DOMAIN, input, &sig)
869 .unwrap_or_else(|e| panic!("input #{i} failed strict verify: {e:?}"));
870 }
871 }
872
873 #[test]
884 fn verify_rejects_non_canonical_high_s_signature() {
885 const L: [u8; 32] = [
887 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9,
888 0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
889 0x00, 0x00, 0x00, 0x10,
890 ];
891
892 let kp = fixed_kp();
893 let bytes = b"malleability test payload";
894 let sig = kp.sign(COMMIT_DOMAIN, bytes);
895 verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("original signature must verify");
896
897 let mut malleated = sig.0;
901 let mut carry: u16 = 0;
902 for i in 0..32 {
903 let sum = u16::from(malleated[32 + i]) + u16::from(L[i]) + carry;
904 malleated[32 + i] = (sum & 0xFF) as u8;
905 carry = sum >> 8;
906 }
907 assert_eq!(carry, 0, "s + L must not overflow 32 bytes");
908 assert_ne!(
909 malleated[32..],
910 sig.0[32..],
911 "the malleated signature must differ from the original"
912 );
913
914 let bad_sig = Signature(malleated);
915 assert!(matches!(
916 verify(&kp.public, COMMIT_DOMAIN, bytes, &bad_sig),
917 Err(MkitError::SignatureInvalid)
918 ));
919 }
920
921 #[test]
926 fn domain_separation_commit_vs_remix() {
927 let kp = fixed_kp();
928 let bytes = b"shared bytes";
929 let sig = kp.sign(COMMIT_DOMAIN, bytes);
930 assert!(matches!(
932 verify(&kp.public, REMIX_DOMAIN, bytes, &sig),
933 Err(MkitError::SignatureInvalid)
934 ));
935 }
936
937 #[test]
938 fn domain_digest_differs_per_domain() {
939 let bytes = b"abc";
940 let a = domain_digest(COMMIT_DOMAIN, bytes);
941 let b = domain_digest(REMIX_DOMAIN, bytes);
942 assert_ne!(a, b);
943 }
944
945 #[test]
955 fn domain_digest_includes_length_prefix() {
956 let domain = b"ab";
957 let msg = b"cX";
958 let got = domain_digest(domain, msg);
959 let mut want = blake3::Hasher::new();
960 let len = u16::try_from(domain.len()).unwrap();
961 want.update(&len.to_le_bytes());
962 want.update(domain);
963 want.update(msg);
964 assert_eq!(got, *want.finalize().as_bytes());
965
966 let other = domain_digest(b"abc", b"X");
969 assert_ne!(got, other);
970 }
971
972 #[test]
985 fn ed25519_rfc8032_vector_1() {
986 let seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
988 let pk_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
989 let sig_hex = concat!(
990 "e5564300c360ac729086e2cc806e828a",
991 "84877f1eb8e5d974d873e06522490155",
992 "5fb8821590a33bacc61e39701cf9b46b",
993 "d25bf5f0595bbe24655141438e7a100b",
994 );
995 let seed: [u8; 32] = hex::decode(seed_hex).unwrap().try_into().unwrap();
996 let kp = KeyPair::from_seed(seed);
997 assert_eq!(hex::encode(kp.public.0), pk_hex);
999 let signing = SigningKey::from_bytes(&kp.secret.0);
1002 let sig = signing.sign(b"");
1003 assert_eq!(hex::encode(sig.to_bytes()), sig_hex);
1004 }
1005
1006 fn build_commit(kp: &KeyPair, msg: &[u8]) -> Commit {
1011 Commit {
1012 tree_hash: hash(b"tree"),
1013 parents: vec![],
1014 author: ed25519_id(kp.public.0),
1015 signer: kp.public.0,
1016 message: msg.to_vec(),
1017 timestamp: 1_711_300_000,
1018 message_hash: ZERO,
1019 content_digest: ZERO,
1020 signature: [0u8; 64],
1021 }
1022 }
1023
1024 #[test]
1025 fn sign_then_verify_commit() {
1026 let kp = fixed_kp();
1027 let mut c = build_commit(&kp, b"hello");
1028 c.signature = sign_commit(&c, &kp).unwrap().0;
1029 verify_commit(&c).expect("verify ok");
1030 }
1031
1032 #[test]
1033 fn tampered_commit_message_fails_verify() {
1034 let kp = fixed_kp();
1035 let mut c = build_commit(&kp, b"hello");
1036 c.signature = sign_commit(&c, &kp).unwrap().0;
1037 c.message = b"tampered".to_vec();
1038 assert!(matches!(
1039 verify_commit(&c),
1040 Err(MkitError::SignatureInvalid)
1041 ));
1042 }
1043
1044 #[test]
1045 fn message_hash_does_not_affect_signing_bytes() {
1046 let kp = fixed_kp();
1051 let mut c1 = build_commit(&kp, b"x");
1052 let mut c2 = c1.clone();
1053 c2.message_hash = hash(b"some annotation");
1054 c2.content_digest = hash(b"another annotation");
1055 let sb1 = commit_signing_bytes(&c1).unwrap();
1056 let sb2 = commit_signing_bytes(&c2).unwrap();
1057 assert_eq!(sb1, sb2);
1058 c1.signature = sign_commit(&c1, &kp).unwrap().0;
1059 c2.signature = c1.signature;
1060 verify_commit(&c2).expect("annotation fields are not signed");
1061 }
1062
1063 #[test]
1064 fn sign_then_verify_remix() {
1065 let kp = fixed_kp();
1066 let mut r = Remix {
1067 tree_hash: hash(b"tree"),
1068 parents: vec![],
1069 sources: vec![RemixSource {
1070 upstream_id: hash(b"upstream"),
1071 commit_hash: hash(b"commit"),
1072 }],
1073 author: ed25519_id(kp.public.0),
1074 signer: kp.public.0,
1075 message: b"remix".to_vec(),
1076 timestamp: 2_000,
1077 signature: [0u8; 64],
1078 };
1079 r.signature = sign_remix(&r, &kp).unwrap().0;
1080 verify_remix(&r).expect("verify ok");
1081 }
1082
1083 fn build_tag(kp: &KeyPair, msg: &[u8]) -> Tag {
1088 Tag {
1089 target: hash(b"target"),
1090 target_type: ObjectType::Commit,
1091 name: b"v1.0.0".to_vec(),
1092 tagger: ed25519_id(kp.public.0),
1093 signer: kp.public.0,
1094 message: msg.to_vec(),
1095 timestamp: 1_711_300_000,
1096 signature: [0u8; 64],
1097 }
1098 }
1099
1100 #[test]
1101 fn sign_then_verify_tag() {
1102 let kp = fixed_kp();
1103 let mut t = build_tag(&kp, b"release");
1104 t.signature = sign_tag(&t, &kp).unwrap().0;
1105 verify_tag(&t).expect("verify ok");
1106 }
1107
1108 #[test]
1109 fn tampered_tag_message_fails_verify() {
1110 let kp = fixed_kp();
1111 let mut t = build_tag(&kp, b"release");
1112 t.signature = sign_tag(&t, &kp).unwrap().0;
1113 t.message = b"tampered".to_vec();
1114 assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1115 }
1116
1117 #[test]
1118 fn tampered_tag_target_fails_verify() {
1119 let kp = fixed_kp();
1120 let mut t = build_tag(&kp, b"release");
1121 t.signature = sign_tag(&t, &kp).unwrap().0;
1122 t.target = hash(b"other");
1123 assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1124 }
1125
1126 #[test]
1127 fn annotated_unsigned_tag_fails_verify() {
1128 let kp = fixed_kp();
1135 let mut t = build_tag(&kp, b"release");
1136 t.signature = [0u8; 64];
1137 assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1138 }
1139
1140 #[test]
1141 fn tag_domain_differs_from_commit_and_remix() {
1142 assert_ne!(TAG_DOMAIN, COMMIT_DOMAIN);
1144 assert_ne!(TAG_DOMAIN, REMIX_DOMAIN);
1145 let bytes = b"abc";
1146 let dt = domain_digest(TAG_DOMAIN, bytes);
1147 assert_ne!(dt, domain_digest(COMMIT_DOMAIN, bytes));
1148 assert_ne!(dt, domain_digest(REMIX_DOMAIN, bytes));
1149 }
1150
1151 #[test]
1155 fn tag_signature_does_not_verify_as_commit_or_remix() {
1156 let kp = fixed_kp();
1157 let bytes = b"shared signing bytes";
1158 let tag_sig = kp.sign(TAG_DOMAIN, bytes);
1159 assert!(matches!(
1160 verify(&kp.public, COMMIT_DOMAIN, bytes, &tag_sig),
1161 Err(MkitError::SignatureInvalid)
1162 ));
1163 assert!(matches!(
1164 verify(&kp.public, REMIX_DOMAIN, bytes, &tag_sig),
1165 Err(MkitError::SignatureInvalid)
1166 ));
1167 let commit_sig = kp.sign(COMMIT_DOMAIN, bytes);
1170 assert!(matches!(
1171 verify(&kp.public, TAG_DOMAIN, bytes, &commit_sig),
1172 Err(MkitError::SignatureInvalid)
1173 ));
1174 }
1175
1176 #[test]
1181 fn signing_is_deterministic() {
1182 let kp = fixed_kp();
1183 let bytes = b"deterministic";
1184 let s1 = kp.sign(COMMIT_DOMAIN, bytes);
1185 let s2 = kp.sign(COMMIT_DOMAIN, bytes);
1186 assert_eq!(s1.0, s2.0);
1187 }
1188
1189 #[test]
1194 fn save_then_load_roundtrip() {
1195 let dir = tempdir();
1196 let p = dir.join("default.key");
1197 let kp = KeyPair::from_seed([0x77; 32]);
1198 save_key(&p, &kp).unwrap();
1199 let kp2 = load_key(&p).unwrap();
1200 assert_eq!(kp.public.0, kp2.public.0);
1201 assert_eq!(kp.secret.0, kp2.secret.0);
1202 }
1203
1204 #[cfg(unix)]
1205 #[test]
1206 fn save_key_writes_mode_0600() {
1207 use std::os::unix::fs::MetadataExt;
1208 let dir = tempdir();
1209 let p = dir.join("default.key");
1210 let kp = KeyPair::from_seed([0x33; 32]);
1211 save_key(&p, &kp).unwrap();
1212 let meta = std::fs::metadata(&p).unwrap();
1213 assert_eq!(meta.mode() & 0o777, 0o600);
1214 }
1215
1216 #[cfg(unix)]
1225 #[test]
1226 fn save_key_tightens_preexisting_wide_mode_to_0600() {
1227 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1228 let dir = tempdir();
1229 let p = dir.join("default.key");
1230 std::fs::write(&p, b"old contents").unwrap();
1232 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1233 perm.set_mode(0o644);
1234 std::fs::set_permissions(&p, perm).unwrap();
1235 assert_eq!(
1236 std::fs::metadata(&p).unwrap().mode() & 0o777,
1237 0o644,
1238 "sanity: pre-seeded 0o644"
1239 );
1240
1241 let kp = KeyPair::from_seed([0x55; 32]);
1242 save_key(&p, &kp).unwrap();
1243
1244 let meta = std::fs::metadata(&p).unwrap();
1245 assert_eq!(meta.mode() & 0o777, 0o600);
1246 }
1247
1248 #[cfg(unix)]
1249 #[test]
1250 fn load_key_rejects_world_readable() {
1251 use std::os::unix::fs::PermissionsExt;
1252 let dir = tempdir();
1253 let p = dir.join("default.key");
1254 let kp = KeyPair::from_seed([0x33; 32]);
1255 save_key(&p, &kp).unwrap();
1256 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1258 perm.set_mode(0o644);
1259 std::fs::set_permissions(&p, perm).unwrap();
1260 match load_key(&p) {
1261 Err(MkitError::InsecureKeyPermissions { actual }) => {
1262 assert_eq!(actual, 0o644);
1263 }
1264 other => panic!("expected InsecureKeyPermissions, got {other:?}"),
1265 }
1266 }
1267
1268 #[test]
1269 fn load_key_rejects_wrong_length() {
1270 let dir = tempdir();
1271 let p = dir.join("short.key");
1272 std::fs::write(&p, b"too short").unwrap();
1273 #[cfg(unix)]
1274 {
1275 use std::os::unix::fs::PermissionsExt;
1276 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1280 perm.set_mode(0o600);
1281 std::fs::set_permissions(&p, perm).unwrap();
1282 let mut dperm = std::fs::metadata(&dir).unwrap().permissions();
1283 dperm.set_mode(0o700);
1284 std::fs::set_permissions(&dir, dperm).unwrap();
1285 }
1286 assert!(matches!(
1287 load_key(&p),
1288 Err(MkitError::InvalidKeyLength { actual: 9 })
1289 ));
1290 }
1291
1292 #[cfg(unix)]
1297 #[test]
1298 fn load_key_rejects_symlink() {
1299 use std::os::unix::fs::PermissionsExt;
1300 let dir = tempdir();
1301 let real = dir.join("real.key");
1302 let kp = KeyPair::from_seed([0xAB; 32]);
1303 save_key(&real, &kp).unwrap();
1304 let link = dir.join("link.key");
1307 std::os::unix::fs::symlink(&real, &link).unwrap();
1308 let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1309 perm.set_mode(0o700);
1310 std::fs::set_permissions(&dir, perm).unwrap();
1311 match load_key(&link) {
1312 Err(MkitError::KeyPathIsSymlink(_)) => {}
1313 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1314 }
1315 }
1316
1317 #[cfg(unix)]
1318 #[test]
1319 fn load_key_rejects_symlinked_ancestor() {
1320 use std::os::unix::fs::PermissionsExt;
1321 let dir = tempdir();
1322 let real_parent = dir.join("realkeys");
1323 std::fs::create_dir_all(&real_parent).unwrap();
1324 let mut parent_perm = std::fs::metadata(&real_parent).unwrap().permissions();
1325 parent_perm.set_mode(0o700);
1326 std::fs::set_permissions(&real_parent, parent_perm).unwrap();
1327
1328 let real = real_parent.join("default.key");
1329 let kp = KeyPair::from_seed([0xBC; 32]);
1330 save_key(&real, &kp).unwrap();
1331
1332 let symlink_parent = dir.join("symlink-keys");
1333 std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1334 match load_key(&symlink_parent.join("default.key")) {
1335 Err(MkitError::KeyPathIsSymlink(_)) => {}
1336 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1337 }
1338 }
1339
1340 #[cfg(unix)]
1344 #[test]
1345 fn load_key_rejects_world_readable_parent() {
1346 use std::os::unix::fs::PermissionsExt;
1347 let dir = tempdir();
1348 let p = dir.join("default.key");
1349 let kp = KeyPair::from_seed([0xCD; 32]);
1350 save_key(&p, &kp).unwrap();
1351 let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1355 perm.set_mode(0o755);
1356 std::fs::set_permissions(&dir, perm).unwrap();
1357 match load_key(&p) {
1358 Err(MkitError::InsecureKeyDir { actual }) => {
1359 assert_eq!(actual, 0o755);
1360 }
1361 other => panic!("expected InsecureKeyDir, got {other:?}"),
1362 }
1363 }
1364
1365 #[cfg(unix)]
1370 #[test]
1371 fn save_key_replaces_existing_key_atomically() {
1372 use std::os::unix::fs::MetadataExt;
1373 let dir = tempdir();
1374 let p = dir.join("default.key");
1375 let kp1 = KeyPair::from_seed([0x11; 32]);
1376 save_key(&p, &kp1).unwrap();
1377 let inode_before = std::fs::metadata(&p).unwrap().ino();
1378
1379 let kp2 = KeyPair::from_seed([0x22; 32]);
1380 save_key(&p, &kp2).unwrap();
1381 let meta_after = std::fs::metadata(&p).unwrap();
1382 assert_ne!(
1385 meta_after.ino(),
1386 inode_before,
1387 "save_key must replace via rename, not truncate-in-place"
1388 );
1389 assert_eq!(meta_after.mode() & 0o777, 0o600);
1390 let kp_loaded = load_key(&p).unwrap();
1391 assert_eq!(kp_loaded.public.0, kp2.public.0);
1392 }
1393
1394 #[test]
1395 fn save_raw_32_create_new_refuses_existing_key() {
1396 let dir = tempdir();
1397 let p = dir.join("default.key");
1398 assert!(save_raw_32_create_new(&p, &[0x11; 32]).unwrap());
1399 assert!(!save_raw_32_create_new(&p, &[0x22; 32]).unwrap());
1400 assert_eq!(&*load_raw_32(&p).unwrap(), &[0x11; 32]);
1401 }
1402
1403 #[cfg(unix)]
1404 #[test]
1405 fn save_key_rejects_symlinked_ancestor() {
1406 let dir = tempdir();
1407 let real_parent = dir.join("realkeys");
1408 std::fs::create_dir_all(&real_parent).unwrap();
1409 let symlink_parent = dir.join("symlink-keys");
1410 std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1411 let kp = KeyPair::from_seed([0x44; 32]);
1412 match save_key(&symlink_parent.join("default.key"), &kp) {
1413 Err(MkitError::KeyPathIsSymlink(_)) => {}
1414 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1415 }
1416 }
1417
1418 #[test]
1428 fn secret_seed_zeroize_clears_bytes() {
1429 let mut s = SecretSeed([0xAAu8; SECRET_KEY_LENGTH]);
1430 s.zeroize();
1431 assert_eq!(s.0, [0u8; SECRET_KEY_LENGTH]);
1432 }
1433
1434 #[test]
1440 fn from_seed_zeroizing_matches_from_seed() {
1441 let raw = [0x9Au8; SECRET_KEY_LENGTH];
1442 let wrapped: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new(raw);
1443 let a = KeyPair::from_seed(raw);
1444 let b = KeyPair::from_seed_zeroizing(&wrapped);
1445 assert_eq!(a.public.0, b.public.0);
1446 assert_eq!(a.secret.0, b.secret.0);
1447 let sig = b.sign(COMMIT_DOMAIN, b"x");
1450 verify(&b.public, COMMIT_DOMAIN, b"x", &sig).expect("verify");
1451 }
1452
1453 #[test]
1463 fn keypair_drop_runs_zeroize_on_secret() {
1464 fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
1465 assert_zeroize_on_drop::<SecretSeed>();
1466
1467 let kp = KeyPair::from_seed([0xDEu8; 32]);
1471 let seed_ref: &SecretSeed = &kp.secret;
1472 assert_eq!(seed_ref.0, [0xDEu8; 32]);
1473 drop(kp);
1474 }
1475
1476 fn tempdir() -> std::path::PathBuf {
1481 use std::sync::atomic::{AtomicU64, Ordering};
1482 use std::time::{SystemTime, UNIX_EPOCH};
1483 static COUNTER: AtomicU64 = AtomicU64::new(0);
1484 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1485 let nanos = SystemTime::now()
1486 .duration_since(UNIX_EPOCH)
1487 .map_or(0, |d| d.as_nanos());
1488 let p =
1489 std::env::temp_dir().join(format!("mkit-sign-test-{nanos}-{n}-{}", std::process::id()));
1490 std::fs::create_dir_all(&p).unwrap();
1491 p
1492 }
1493}