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]
166 pub fn from_seed(mut seed: [u8; SECRET_KEY_LENGTH]) -> Self {
167 let signing = SigningKey::from_bytes(&seed);
168 let public = PublicKey(signing.verifying_key().to_bytes());
169 let secret = SecretSeed(seed);
174 seed.zeroize();
175 Self { public, secret }
176 }
177
178 #[must_use]
191 pub fn from_seed_zeroizing(seed: &Zeroizing<[u8; SECRET_KEY_LENGTH]>) -> Self {
192 let signing = SigningKey::from_bytes(seed);
193 let public = PublicKey(signing.verifying_key().to_bytes());
194 let mut secret_bytes = [0u8; SECRET_KEY_LENGTH];
199 secret_bytes.copy_from_slice(seed.as_slice());
200 let secret = SecretSeed(secret_bytes);
201 secret_bytes.zeroize();
205 Self { public, secret }
206 }
207
208 #[must_use]
212 pub fn sign(&self, domain: &[u8], signing_bytes: &[u8]) -> Signature {
213 let digest = domain_digest(domain, signing_bytes);
214 let signing = SigningKey::from_bytes(&self.secret.0);
215 let sig = signing.sign(&digest);
216 Signature(sig.to_bytes())
217 }
218}
219
220pub fn verify(
239 public: &PublicKey,
240 domain: &[u8],
241 signing_bytes: &[u8],
242 sig: &Signature,
243) -> Result<(), MkitError> {
244 let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
245 let dalek_sig = DalekSignature::from_bytes(&sig.0);
246 let digest = domain_digest(domain, signing_bytes);
247 vk.verify_strict(&digest, &dalek_sig)
248 .map_err(|_| MkitError::SignatureInvalid)
249}
250
251#[must_use]
282fn domain_digest(domain: &[u8], signing_bytes: &[u8]) -> [u8; HASH_LEN] {
283 crate::hash::domain_digest(domain, signing_bytes)
284}
285
286pub fn commit_signing_hash(c: &Commit) -> Result<Hash, MkitError> {
289 let sb = commit_signing_bytes(c)?;
290 Ok(domain_digest(COMMIT_DOMAIN, &sb))
291}
292
293pub fn remix_signing_hash(r: &Remix) -> Result<Hash, MkitError> {
296 let sb = remix_signing_bytes(r)?;
297 Ok(domain_digest(REMIX_DOMAIN, &sb))
298}
299
300pub fn tag_signing_hash(t: &Tag) -> Result<Hash, MkitError> {
303 let sb = tag_signing_bytes(t)?;
304 Ok(domain_digest(TAG_DOMAIN, &sb))
305}
306
307fn write_prologue(buf: &mut Vec<u8>, t: ObjectType) {
308 buf.push(t as u8);
309 buf.extend_from_slice(&MAGIC);
310 buf.push(SCHEMA_VERSION);
311}
312
313fn write_identity(buf: &mut Vec<u8>, id: &Identity) -> Result<(), MkitError> {
314 if !id.is_valid() {
315 return Err(MkitError::InvalidIdentity);
316 }
317 buf.push(id.kind as u8);
318 let len = u16::try_from(id.bytes.len()).map_err(|_| MkitError::IdentityTooLarge)?;
319 buf.extend_from_slice(&len.to_le_bytes());
320 buf.extend_from_slice(&id.bytes);
321 Ok(())
322}
323
324pub fn commit_signing_bytes(c: &Commit) -> Result<Vec<u8>, MkitError> {
337 let mut buf = Vec::with_capacity(
338 6 + 32 + 4 + c.parents.len() * 32 + 3 + c.author.bytes.len() + 4 + c.message.len() + 8 + 32,
339 );
340 write_prologue(&mut buf, ObjectType::Commit);
341 buf.extend_from_slice(&c.tree_hash);
342 let parent_count = u32::try_from(c.parents.len()).map_err(|_| MkitError::TooManyParents)?;
343 buf.extend_from_slice(&parent_count.to_le_bytes());
344 for p in &c.parents {
345 buf.extend_from_slice(p);
346 }
347 write_identity(&mut buf, &c.author)?;
348 let mlen = u32::try_from(c.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
349 buf.extend_from_slice(&mlen.to_le_bytes());
350 buf.extend_from_slice(&c.message);
351 buf.extend_from_slice(&c.timestamp.to_le_bytes());
352 buf.extend_from_slice(&c.signer);
353 Ok(buf)
354}
355
356pub fn remix_signing_bytes(r: &Remix) -> Result<Vec<u8>, MkitError> {
359 let mut buf = Vec::with_capacity(
360 6 + 32
361 + 4
362 + r.parents.len() * 32
363 + 4
364 + r.sources.len() * 64
365 + 3
366 + r.author.bytes.len()
367 + 4
368 + r.message.len()
369 + 8
370 + 32,
371 );
372 write_prologue(&mut buf, ObjectType::Remix);
373 buf.extend_from_slice(&r.tree_hash);
374 let parent_count = u32::try_from(r.parents.len()).map_err(|_| MkitError::TooManyParents)?;
375 buf.extend_from_slice(&parent_count.to_le_bytes());
376 for p in &r.parents {
377 buf.extend_from_slice(p);
378 }
379 let source_count = u32::try_from(r.sources.len()).map_err(|_| MkitError::TooManySources)?;
380 buf.extend_from_slice(&source_count.to_le_bytes());
381 for s in &r.sources {
382 buf.extend_from_slice(&s.upstream_id);
383 buf.extend_from_slice(&s.commit_hash);
384 }
385 write_identity(&mut buf, &r.author)?;
386 let mlen = u32::try_from(r.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
387 buf.extend_from_slice(&mlen.to_le_bytes());
388 buf.extend_from_slice(&r.message);
389 buf.extend_from_slice(&r.timestamp.to_le_bytes());
390 buf.extend_from_slice(&r.signer);
391 Ok(buf)
392}
393
394pub fn tag_signing_bytes(t: &Tag) -> Result<Vec<u8>, MkitError> {
407 if !t.name_is_valid() {
408 return Err(MkitError::TagNameInvalid);
409 }
410 if matches!(t.target_type, ObjectType::Delta) {
411 return Err(MkitError::TagTargetTypeInvalid(t.target_type as u8));
412 }
413 let mut buf = Vec::with_capacity(
414 6 + 32 + 1 + 4 + t.name.len() + 3 + t.tagger.bytes.len() + 4 + t.message.len() + 8 + 32,
415 );
416 write_prologue(&mut buf, ObjectType::Tag);
417 buf.extend_from_slice(&t.target);
418 buf.push(t.target_type as u8);
419 let nlen = u32::try_from(t.name.len()).map_err(|_| MkitError::TagNameInvalid)?;
420 buf.extend_from_slice(&nlen.to_le_bytes());
421 buf.extend_from_slice(&t.name);
422 write_identity(&mut buf, &t.tagger)?;
423 let mlen = u32::try_from(t.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
424 buf.extend_from_slice(&mlen.to_le_bytes());
425 buf.extend_from_slice(&t.message);
426 buf.extend_from_slice(&t.timestamp.to_le_bytes());
427 buf.extend_from_slice(&t.signer);
428 Ok(buf)
429}
430
431pub fn sign_tag(t: &Tag, kp: &KeyPair) -> Result<Signature, MkitError> {
433 let sb = tag_signing_bytes(t)?;
434 Ok(kp.sign(TAG_DOMAIN, &sb))
435}
436
437pub fn verify_tag(t: &Tag) -> Result<(), MkitError> {
439 let sb = tag_signing_bytes(t)?;
440 let pk = PublicKey(t.signer);
441 let sig = Signature(t.signature);
442 verify(&pk, TAG_DOMAIN, &sb, &sig)
443}
444
445pub fn sign_commit(c: &Commit, kp: &KeyPair) -> Result<Signature, MkitError> {
447 let sb = commit_signing_bytes(c)?;
448 Ok(kp.sign(COMMIT_DOMAIN, &sb))
449}
450
451pub fn sign_remix(r: &Remix, kp: &KeyPair) -> Result<Signature, MkitError> {
453 let sb = remix_signing_bytes(r)?;
454 Ok(kp.sign(REMIX_DOMAIN, &sb))
455}
456
457pub fn verify_commit(c: &Commit) -> Result<(), MkitError> {
463 let sb = commit_signing_bytes(c)?;
464 let pk = PublicKey(c.signer);
465 let sig = Signature(c.signature);
466 verify(&pk, COMMIT_DOMAIN, &sb, &sig)
467}
468
469pub fn verify_remix(r: &Remix) -> Result<(), MkitError> {
471 let sb = remix_signing_bytes(r)?;
472 let pk = PublicKey(r.signer);
473 let sig = Signature(r.signature);
474 verify(&pk, REMIX_DOMAIN, &sb, &sig)
475}
476
477pub fn load_key(path: &Path) -> Result<KeyPair, MkitError> {
496 let seed = load_raw_32(path)?;
497 Ok(KeyPair::from_seed_zeroizing(&seed))
500}
501
502pub fn load_raw_32(path: &Path) -> Result<zeroize::Zeroizing<[u8; 32]>, MkitError> {
508 #[cfg(unix)]
509 {
510 use std::io::Read as _;
511 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
512 ensure_no_symlink_ancestors(path)?;
513 let mut f = std::fs::OpenOptions::new()
514 .read(true)
515 .custom_flags(libc::O_NOFOLLOW)
516 .open(path)
517 .map_err(|e| {
518 if e.raw_os_error() == Some(libc::ELOOP) {
519 MkitError::KeyPathIsSymlink(path.display().to_string())
520 } else {
521 MkitError::KeyIo(format!("open: {e}"))
522 }
523 })?;
524
525 let meta = f
529 .metadata()
530 .map_err(|e| MkitError::KeyIo(format!("fstat: {e}")))?;
531
532 let mode = meta.mode() & 0o777;
533 if mode & 0o077 != 0 {
534 return Err(MkitError::InsecureKeyPermissions { actual: mode });
535 }
536
537 let euid = effective_uid();
543 if meta.uid() != euid {
544 return Err(MkitError::InsecureKeyOwner {
545 actual: meta.uid(),
546 euid,
547 });
548 }
549
550 if let Some(parent) = path.parent()
555 && !parent.as_os_str().is_empty()
556 {
557 check_parent_dir_secure(parent)?;
558 }
559
560 let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
561 if let Err(e) = f.read_exact(seed.as_mut_slice()) {
562 return if e.kind() == std::io::ErrorKind::UnexpectedEof {
563 Err(MkitError::InvalidKeyLength {
564 actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
565 })
566 } else {
567 Err(MkitError::KeyIo(format!("read: {e}")))
568 };
569 }
570 let mut probe = [0u8; 1];
574 let trailing = f
575 .read(&mut probe)
576 .map_err(|e| MkitError::KeyIo(format!("read trailing byte: {e}")))?;
577 if trailing != 0 {
578 return Err(MkitError::InvalidKeyLength {
579 actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
580 });
581 }
582 Ok(seed)
583 }
584 #[cfg(not(unix))]
585 {
586 let raw = std::fs::read(path).map_err(|e| MkitError::KeyIo(format!("read: {e}")))?;
587 if raw.len() != SECRET_KEY_LENGTH {
588 return Err(MkitError::InvalidKeyLength { actual: raw.len() });
589 }
590 let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
591 seed.copy_from_slice(&raw);
592 let mut raw = raw;
593 raw.zeroize();
594 Ok(seed)
595 }
596}
597
598#[cfg(unix)]
599fn check_parent_dir_secure(parent: &Path) -> Result<(), MkitError> {
600 use std::os::unix::fs::MetadataExt;
601 let Ok(meta) = std::fs::metadata(parent) else {
604 return Ok(());
605 };
606 let mode = meta.mode() & 0o777;
607 if mode & 0o077 != 0 {
608 return Err(MkitError::InsecureKeyDir { actual: mode });
609 }
610 Ok(())
611}
612
613#[cfg(unix)]
614fn ensure_no_symlink_ancestors(path: &Path) -> Result<(), MkitError> {
615 let mut current = path.parent();
616 for _ in 0..3 {
617 let Some(dir) = current else {
618 break;
619 };
620 if dir.as_os_str().is_empty() {
621 break;
622 }
623 match std::fs::symlink_metadata(dir) {
624 Ok(meta) if meta.file_type().is_symlink() => {
625 return Err(MkitError::KeyPathIsSymlink(dir.display().to_string()));
626 }
627 Ok(_) => {}
628 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
629 Err(e) => return Err(MkitError::KeyIo(format!("lstat {}: {e}", dir.display()))),
630 }
631 current = dir.parent();
632 }
633 if let Ok(meta) = std::fs::symlink_metadata(path)
634 && meta.file_type().is_symlink()
635 {
636 return Err(MkitError::KeyPathIsSymlink(path.display().to_string()));
637 }
638 Ok(())
639}
640
641#[cfg(unix)]
642fn create_secure_dir_all(parent: &Path) -> Result<(), MkitError> {
643 use std::os::unix::fs::PermissionsExt;
644
645 ensure_no_symlink_ancestors(parent)?;
646 std::fs::create_dir_all(parent)
647 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
648 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
649 .map_err(|e| MkitError::KeyIo(format!("chmod parent: {e}")))?;
650 Ok(())
651}
652
653pub fn save_key(path: &Path, kp: &KeyPair) -> Result<(), MkitError> {
670 save_raw_32(path, &kp.secret.0)
671}
672
673pub fn save_raw_32(path: &Path, secret: &[u8; 32]) -> Result<(), MkitError> {
675 let parent: &Path = match path.parent() {
676 Some(p) if !p.as_os_str().is_empty() => p,
677 _ => Path::new("."),
678 };
679
680 #[cfg(unix)]
681 {
682 use std::io::Write as _;
683 use std::os::unix::fs::OpenOptionsExt;
684 create_secure_dir_all(parent)?;
685
686 let filename = path
687 .file_name()
688 .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
689 let tmp_name = {
693 let mut s = std::ffi::OsString::from(".");
694 s.push(filename);
695 s.push(format!(".tmp.{}", std::process::id()));
696 s
697 };
698 let tmp_path = parent.join(&tmp_name);
699
700 let mut f = std::fs::OpenOptions::new()
702 .write(true)
703 .create_new(true)
704 .custom_flags(libc::O_NOFOLLOW)
705 .mode(0o600)
706 .open(&tmp_path)
707 .map_err(|e| MkitError::KeyIo(format!("open tmp {}: {e}", tmp_path.display())))?;
708 if let Err(e) = f.write_all(secret) {
709 let _ = std::fs::remove_file(&tmp_path);
710 return Err(MkitError::KeyIo(format!("write: {e}")));
711 }
712 if let Err(e) = f.sync_all() {
713 let _ = std::fs::remove_file(&tmp_path);
714 return Err(MkitError::KeyIo(format!("fsync tmp: {e}")));
715 }
716 drop(f);
719
720 if let Err(e) = std::fs::rename(&tmp_path, path) {
721 let _ = std::fs::remove_file(&tmp_path);
722 return Err(MkitError::KeyIo(format!("rename: {e}")));
723 }
724
725 let dir = std::fs::File::open(parent)
729 .map_err(|e| MkitError::KeyIo(format!("open dir for fsync: {e}")))?;
730 dir.sync_all()
731 .map_err(|e| MkitError::KeyIo(format!("fsync dir: {e}")))?;
732 }
733 #[cfg(not(unix))]
734 {
735 std::fs::create_dir_all(parent)
736 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
737 let filename = path
741 .file_name()
742 .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
743 let mut tmp_name = std::ffi::OsString::from(".");
744 tmp_name.push(filename);
745 tmp_name.push(format!(".tmp.{}", std::process::id()));
746 let tmp_path = parent.join(&tmp_name);
747 std::fs::write(&tmp_path, secret)
748 .map_err(|e| MkitError::KeyIo(format!("write tmp: {e}")))?;
749 if let Err(e) = std::fs::rename(&tmp_path, path) {
750 let _ = std::fs::remove_file(&tmp_path);
751 return Err(MkitError::KeyIo(format!("rename: {e}")));
752 }
753 }
754 Ok(())
755}
756
757pub fn save_raw_32_create_new(path: &Path, secret: &[u8; 32]) -> Result<bool, MkitError> {
763 let parent: &Path = match path.parent() {
764 Some(p) if !p.as_os_str().is_empty() => p,
765 _ => Path::new("."),
766 };
767
768 #[cfg(unix)]
769 create_secure_dir_all(parent)?;
770 #[cfg(not(unix))]
771 std::fs::create_dir_all(parent)
772 .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
773
774 crate::atomic::write_create_new(path, secret, false)
775 .map_err(|e| MkitError::KeyIo(format!("create key: {e}")))
776}
777
778#[cfg(test)]
783mod tests {
784 use super::*;
785 use crate::hash::{ZERO, hash};
786 use crate::object::{Identity, IdentityKind, ObjectType, RemixSource, Tag};
787
788 fn fixed_kp() -> KeyPair {
789 KeyPair::from_seed([0x42; 32])
790 }
791
792 fn ed25519_id(pk: [u8; 32]) -> Identity {
793 Identity {
794 kind: IdentityKind::Ed25519,
795 bytes: pk.to_vec(),
796 }
797 }
798
799 #[test]
804 fn sign_verify_roundtrip() {
805 let kp = fixed_kp();
806 let bytes = b"some signing bytes";
807 let sig = kp.sign(COMMIT_DOMAIN, bytes);
808 verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("verify ok");
809 }
810
811 #[test]
812 fn verify_rejects_tampered_input() {
813 let kp = fixed_kp();
814 let bytes = b"original".to_vec();
815 let sig = kp.sign(COMMIT_DOMAIN, &bytes);
816 let mut tampered = bytes.clone();
817 tampered[0] ^= 0x01;
818 assert!(matches!(
819 verify(&kp.public, COMMIT_DOMAIN, &tampered, &sig),
820 Err(MkitError::SignatureInvalid)
821 ));
822 }
823
824 #[test]
825 fn verify_rejects_wrong_key() {
826 let kp1 = fixed_kp();
827 let kp2 = KeyPair::from_seed([0x55; 32]);
828 let bytes = b"x";
829 let sig = kp1.sign(COMMIT_DOMAIN, bytes);
830 assert!(matches!(
831 verify(&kp2.public, COMMIT_DOMAIN, bytes, &sig),
832 Err(MkitError::SignatureInvalid)
833 ));
834 }
835
836 #[test]
845 fn our_signatures_pass_strict_verify() {
846 let kp = fixed_kp();
847 for (i, input) in [
852 b"" as &[u8],
853 b"a",
854 b"00000000000000000000000000000000",
855 &[0xff; 64],
856 &(0u8..=255).collect::<Vec<u8>>(),
857 ]
858 .iter()
859 .enumerate()
860 {
861 let sig = kp.sign(COMMIT_DOMAIN, input);
862 verify(&kp.public, COMMIT_DOMAIN, input, &sig)
863 .unwrap_or_else(|e| panic!("input #{i} failed strict verify: {e:?}"));
864 }
865 }
866
867 #[test]
872 fn domain_separation_commit_vs_remix() {
873 let kp = fixed_kp();
874 let bytes = b"shared bytes";
875 let sig = kp.sign(COMMIT_DOMAIN, bytes);
876 assert!(matches!(
878 verify(&kp.public, REMIX_DOMAIN, bytes, &sig),
879 Err(MkitError::SignatureInvalid)
880 ));
881 }
882
883 #[test]
884 fn domain_digest_differs_per_domain() {
885 let bytes = b"abc";
886 let a = domain_digest(COMMIT_DOMAIN, bytes);
887 let b = domain_digest(REMIX_DOMAIN, bytes);
888 assert_ne!(a, b);
889 }
890
891 #[test]
901 fn domain_digest_includes_length_prefix() {
902 let domain = b"ab";
903 let msg = b"cX";
904 let got = domain_digest(domain, msg);
905 let mut want = blake3::Hasher::new();
906 let len = u16::try_from(domain.len()).unwrap();
907 want.update(&len.to_le_bytes());
908 want.update(domain);
909 want.update(msg);
910 assert_eq!(got, *want.finalize().as_bytes());
911
912 let other = domain_digest(b"abc", b"X");
915 assert_ne!(got, other);
916 }
917
918 #[test]
931 fn ed25519_rfc8032_vector_1() {
932 let seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
934 let pk_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
935 let sig_hex = concat!(
936 "e5564300c360ac729086e2cc806e828a",
937 "84877f1eb8e5d974d873e06522490155",
938 "5fb8821590a33bacc61e39701cf9b46b",
939 "d25bf5f0595bbe24655141438e7a100b",
940 );
941 let seed: [u8; 32] = hex::decode(seed_hex).unwrap().try_into().unwrap();
942 let kp = KeyPair::from_seed(seed);
943 assert_eq!(hex::encode(kp.public.0), pk_hex);
945 let signing = SigningKey::from_bytes(&kp.secret.0);
948 let sig = signing.sign(b"");
949 assert_eq!(hex::encode(sig.to_bytes()), sig_hex);
950 }
951
952 fn build_commit(kp: &KeyPair, msg: &[u8]) -> Commit {
957 Commit {
958 tree_hash: hash(b"tree"),
959 parents: vec![],
960 author: ed25519_id(kp.public.0),
961 signer: kp.public.0,
962 message: msg.to_vec(),
963 timestamp: 1_711_300_000,
964 message_hash: ZERO,
965 content_digest: ZERO,
966 signature: [0u8; 64],
967 }
968 }
969
970 #[test]
971 fn sign_then_verify_commit() {
972 let kp = fixed_kp();
973 let mut c = build_commit(&kp, b"hello");
974 c.signature = sign_commit(&c, &kp).unwrap().0;
975 verify_commit(&c).expect("verify ok");
976 }
977
978 #[test]
979 fn tampered_commit_message_fails_verify() {
980 let kp = fixed_kp();
981 let mut c = build_commit(&kp, b"hello");
982 c.signature = sign_commit(&c, &kp).unwrap().0;
983 c.message = b"tampered".to_vec();
984 assert!(matches!(
985 verify_commit(&c),
986 Err(MkitError::SignatureInvalid)
987 ));
988 }
989
990 #[test]
991 fn message_hash_does_not_affect_signing_bytes() {
992 let kp = fixed_kp();
997 let mut c1 = build_commit(&kp, b"x");
998 let mut c2 = c1.clone();
999 c2.message_hash = hash(b"some annotation");
1000 c2.content_digest = hash(b"another annotation");
1001 let sb1 = commit_signing_bytes(&c1).unwrap();
1002 let sb2 = commit_signing_bytes(&c2).unwrap();
1003 assert_eq!(sb1, sb2);
1004 c1.signature = sign_commit(&c1, &kp).unwrap().0;
1005 c2.signature = c1.signature;
1006 verify_commit(&c2).expect("annotation fields are not signed");
1007 }
1008
1009 #[test]
1010 fn sign_then_verify_remix() {
1011 let kp = fixed_kp();
1012 let mut r = Remix {
1013 tree_hash: hash(b"tree"),
1014 parents: vec![],
1015 sources: vec![RemixSource {
1016 upstream_id: hash(b"upstream"),
1017 commit_hash: hash(b"commit"),
1018 }],
1019 author: ed25519_id(kp.public.0),
1020 signer: kp.public.0,
1021 message: b"remix".to_vec(),
1022 timestamp: 2_000,
1023 signature: [0u8; 64],
1024 };
1025 r.signature = sign_remix(&r, &kp).unwrap().0;
1026 verify_remix(&r).expect("verify ok");
1027 }
1028
1029 fn build_tag(kp: &KeyPair, msg: &[u8]) -> Tag {
1034 Tag {
1035 target: hash(b"target"),
1036 target_type: ObjectType::Commit,
1037 name: b"v1.0.0".to_vec(),
1038 tagger: ed25519_id(kp.public.0),
1039 signer: kp.public.0,
1040 message: msg.to_vec(),
1041 timestamp: 1_711_300_000,
1042 signature: [0u8; 64],
1043 }
1044 }
1045
1046 #[test]
1047 fn sign_then_verify_tag() {
1048 let kp = fixed_kp();
1049 let mut t = build_tag(&kp, b"release");
1050 t.signature = sign_tag(&t, &kp).unwrap().0;
1051 verify_tag(&t).expect("verify ok");
1052 }
1053
1054 #[test]
1055 fn tampered_tag_message_fails_verify() {
1056 let kp = fixed_kp();
1057 let mut t = build_tag(&kp, b"release");
1058 t.signature = sign_tag(&t, &kp).unwrap().0;
1059 t.message = b"tampered".to_vec();
1060 assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1061 }
1062
1063 #[test]
1064 fn tampered_tag_target_fails_verify() {
1065 let kp = fixed_kp();
1066 let mut t = build_tag(&kp, b"release");
1067 t.signature = sign_tag(&t, &kp).unwrap().0;
1068 t.target = hash(b"other");
1069 assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1070 }
1071
1072 #[test]
1073 fn tag_domain_differs_from_commit_and_remix() {
1074 assert_ne!(TAG_DOMAIN, COMMIT_DOMAIN);
1076 assert_ne!(TAG_DOMAIN, REMIX_DOMAIN);
1077 let bytes = b"abc";
1078 let dt = domain_digest(TAG_DOMAIN, bytes);
1079 assert_ne!(dt, domain_digest(COMMIT_DOMAIN, bytes));
1080 assert_ne!(dt, domain_digest(REMIX_DOMAIN, bytes));
1081 }
1082
1083 #[test]
1087 fn tag_signature_does_not_verify_as_commit_or_remix() {
1088 let kp = fixed_kp();
1089 let bytes = b"shared signing bytes";
1090 let tag_sig = kp.sign(TAG_DOMAIN, bytes);
1091 assert!(matches!(
1092 verify(&kp.public, COMMIT_DOMAIN, bytes, &tag_sig),
1093 Err(MkitError::SignatureInvalid)
1094 ));
1095 assert!(matches!(
1096 verify(&kp.public, REMIX_DOMAIN, bytes, &tag_sig),
1097 Err(MkitError::SignatureInvalid)
1098 ));
1099 let commit_sig = kp.sign(COMMIT_DOMAIN, bytes);
1102 assert!(matches!(
1103 verify(&kp.public, TAG_DOMAIN, bytes, &commit_sig),
1104 Err(MkitError::SignatureInvalid)
1105 ));
1106 }
1107
1108 #[test]
1113 fn signing_is_deterministic() {
1114 let kp = fixed_kp();
1115 let bytes = b"deterministic";
1116 let s1 = kp.sign(COMMIT_DOMAIN, bytes);
1117 let s2 = kp.sign(COMMIT_DOMAIN, bytes);
1118 assert_eq!(s1.0, s2.0);
1119 }
1120
1121 #[test]
1126 fn save_then_load_roundtrip() {
1127 let dir = tempdir();
1128 let p = dir.join("default.key");
1129 let kp = KeyPair::from_seed([0x77; 32]);
1130 save_key(&p, &kp).unwrap();
1131 let kp2 = load_key(&p).unwrap();
1132 assert_eq!(kp.public.0, kp2.public.0);
1133 assert_eq!(kp.secret.0, kp2.secret.0);
1134 }
1135
1136 #[cfg(unix)]
1137 #[test]
1138 fn save_key_writes_mode_0600() {
1139 use std::os::unix::fs::MetadataExt;
1140 let dir = tempdir();
1141 let p = dir.join("default.key");
1142 let kp = KeyPair::from_seed([0x33; 32]);
1143 save_key(&p, &kp).unwrap();
1144 let meta = std::fs::metadata(&p).unwrap();
1145 assert_eq!(meta.mode() & 0o777, 0o600);
1146 }
1147
1148 #[cfg(unix)]
1155 #[test]
1156 fn save_key_tightens_preexisting_wide_mode_to_0600() {
1157 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1158 let dir = tempdir();
1159 let p = dir.join("default.key");
1160 std::fs::write(&p, b"old contents").unwrap();
1162 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1163 perm.set_mode(0o644);
1164 std::fs::set_permissions(&p, perm).unwrap();
1165 assert_eq!(
1166 std::fs::metadata(&p).unwrap().mode() & 0o777,
1167 0o644,
1168 "sanity: pre-seeded 0o644"
1169 );
1170
1171 let kp = KeyPair::from_seed([0x55; 32]);
1172 save_key(&p, &kp).unwrap();
1173
1174 let meta = std::fs::metadata(&p).unwrap();
1175 assert_eq!(meta.mode() & 0o777, 0o600);
1176 }
1177
1178 #[cfg(unix)]
1179 #[test]
1180 fn load_key_rejects_world_readable() {
1181 use std::os::unix::fs::PermissionsExt;
1182 let dir = tempdir();
1183 let p = dir.join("default.key");
1184 let kp = KeyPair::from_seed([0x33; 32]);
1185 save_key(&p, &kp).unwrap();
1186 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1188 perm.set_mode(0o644);
1189 std::fs::set_permissions(&p, perm).unwrap();
1190 match load_key(&p) {
1191 Err(MkitError::InsecureKeyPermissions { actual }) => {
1192 assert_eq!(actual, 0o644);
1193 }
1194 other => panic!("expected InsecureKeyPermissions, got {other:?}"),
1195 }
1196 }
1197
1198 #[test]
1199 fn load_key_rejects_wrong_length() {
1200 let dir = tempdir();
1201 let p = dir.join("short.key");
1202 std::fs::write(&p, b"too short").unwrap();
1203 #[cfg(unix)]
1204 {
1205 use std::os::unix::fs::PermissionsExt;
1206 let mut perm = std::fs::metadata(&p).unwrap().permissions();
1210 perm.set_mode(0o600);
1211 std::fs::set_permissions(&p, perm).unwrap();
1212 let mut dperm = std::fs::metadata(&dir).unwrap().permissions();
1213 dperm.set_mode(0o700);
1214 std::fs::set_permissions(&dir, dperm).unwrap();
1215 }
1216 assert!(matches!(
1217 load_key(&p),
1218 Err(MkitError::InvalidKeyLength { actual: 9 })
1219 ));
1220 }
1221
1222 #[cfg(unix)]
1227 #[test]
1228 fn load_key_rejects_symlink() {
1229 use std::os::unix::fs::PermissionsExt;
1230 let dir = tempdir();
1231 let real = dir.join("real.key");
1232 let kp = KeyPair::from_seed([0xAB; 32]);
1233 save_key(&real, &kp).unwrap();
1234 let link = dir.join("link.key");
1237 std::os::unix::fs::symlink(&real, &link).unwrap();
1238 let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1239 perm.set_mode(0o700);
1240 std::fs::set_permissions(&dir, perm).unwrap();
1241 match load_key(&link) {
1242 Err(MkitError::KeyPathIsSymlink(_)) => {}
1243 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1244 }
1245 }
1246
1247 #[cfg(unix)]
1248 #[test]
1249 fn load_key_rejects_symlinked_ancestor() {
1250 use std::os::unix::fs::PermissionsExt;
1251 let dir = tempdir();
1252 let real_parent = dir.join("realkeys");
1253 std::fs::create_dir_all(&real_parent).unwrap();
1254 let mut parent_perm = std::fs::metadata(&real_parent).unwrap().permissions();
1255 parent_perm.set_mode(0o700);
1256 std::fs::set_permissions(&real_parent, parent_perm).unwrap();
1257
1258 let real = real_parent.join("default.key");
1259 let kp = KeyPair::from_seed([0xBC; 32]);
1260 save_key(&real, &kp).unwrap();
1261
1262 let symlink_parent = dir.join("symlink-keys");
1263 std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1264 match load_key(&symlink_parent.join("default.key")) {
1265 Err(MkitError::KeyPathIsSymlink(_)) => {}
1266 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1267 }
1268 }
1269
1270 #[cfg(unix)]
1274 #[test]
1275 fn load_key_rejects_world_readable_parent() {
1276 use std::os::unix::fs::PermissionsExt;
1277 let dir = tempdir();
1278 let p = dir.join("default.key");
1279 let kp = KeyPair::from_seed([0xCD; 32]);
1280 save_key(&p, &kp).unwrap();
1281 let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1285 perm.set_mode(0o755);
1286 std::fs::set_permissions(&dir, perm).unwrap();
1287 match load_key(&p) {
1288 Err(MkitError::InsecureKeyDir { actual }) => {
1289 assert_eq!(actual, 0o755);
1290 }
1291 other => panic!("expected InsecureKeyDir, got {other:?}"),
1292 }
1293 }
1294
1295 #[cfg(unix)]
1300 #[test]
1301 fn save_key_replaces_existing_key_atomically() {
1302 use std::os::unix::fs::MetadataExt;
1303 let dir = tempdir();
1304 let p = dir.join("default.key");
1305 let kp1 = KeyPair::from_seed([0x11; 32]);
1306 save_key(&p, &kp1).unwrap();
1307 let inode_before = std::fs::metadata(&p).unwrap().ino();
1308
1309 let kp2 = KeyPair::from_seed([0x22; 32]);
1310 save_key(&p, &kp2).unwrap();
1311 let meta_after = std::fs::metadata(&p).unwrap();
1312 assert_ne!(
1315 meta_after.ino(),
1316 inode_before,
1317 "save_key must replace via rename, not truncate-in-place"
1318 );
1319 assert_eq!(meta_after.mode() & 0o777, 0o600);
1320 let kp_loaded = load_key(&p).unwrap();
1321 assert_eq!(kp_loaded.public.0, kp2.public.0);
1322 }
1323
1324 #[test]
1325 fn save_raw_32_create_new_refuses_existing_key() {
1326 let dir = tempdir();
1327 let p = dir.join("default.key");
1328 assert!(save_raw_32_create_new(&p, &[0x11; 32]).unwrap());
1329 assert!(!save_raw_32_create_new(&p, &[0x22; 32]).unwrap());
1330 assert_eq!(&*load_raw_32(&p).unwrap(), &[0x11; 32]);
1331 }
1332
1333 #[cfg(unix)]
1334 #[test]
1335 fn save_key_rejects_symlinked_ancestor() {
1336 let dir = tempdir();
1337 let real_parent = dir.join("realkeys");
1338 std::fs::create_dir_all(&real_parent).unwrap();
1339 let symlink_parent = dir.join("symlink-keys");
1340 std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1341 let kp = KeyPair::from_seed([0x44; 32]);
1342 match save_key(&symlink_parent.join("default.key"), &kp) {
1343 Err(MkitError::KeyPathIsSymlink(_)) => {}
1344 other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1345 }
1346 }
1347
1348 #[test]
1358 fn secret_seed_zeroize_clears_bytes() {
1359 let mut s = SecretSeed([0xAAu8; SECRET_KEY_LENGTH]);
1360 s.zeroize();
1361 assert_eq!(s.0, [0u8; SECRET_KEY_LENGTH]);
1362 }
1363
1364 #[test]
1369 fn zeroizing_seed_scrubs_on_drop() {
1370 use zeroize::Zeroize;
1381 let mut s: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new([0xCDu8; SECRET_KEY_LENGTH]);
1382 s.zeroize();
1384 assert_eq!(*s, [0u8; SECRET_KEY_LENGTH]);
1385 }
1386
1387 #[test]
1393 fn from_seed_zeroizing_matches_from_seed() {
1394 let raw = [0x9Au8; SECRET_KEY_LENGTH];
1395 let wrapped: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new(raw);
1396 let a = KeyPair::from_seed(raw);
1397 let b = KeyPair::from_seed_zeroizing(&wrapped);
1398 assert_eq!(a.public.0, b.public.0);
1399 assert_eq!(a.secret.0, b.secret.0);
1400 let sig = b.sign(COMMIT_DOMAIN, b"x");
1403 verify(&b.public, COMMIT_DOMAIN, b"x", &sig).expect("verify");
1404 }
1405
1406 #[test]
1417 fn from_seed_scrubs_owned_param() {
1418 let mut seed = [0x5Au8; SECRET_KEY_LENGTH];
1421 let kp = KeyPair::from_seed(seed);
1422 assert_ne!(kp.public.0, [0u8; 32], "public key derived");
1424
1425 seed.zeroize();
1429 assert_eq!(seed, [0u8; SECRET_KEY_LENGTH], "owned seed scrubs to zero");
1430 }
1431
1432 #[test]
1439 fn keypair_drop_runs_zeroize_on_secret() {
1440 use std::sync::Arc;
1441 use std::sync::atomic::{AtomicBool, Ordering};
1442
1443 struct DropFlag {
1449 flag: Arc<AtomicBool>,
1450 bytes: [u8; 32],
1451 }
1452 impl Zeroize for DropFlag {
1453 fn zeroize(&mut self) {
1454 self.bytes.zeroize();
1455 }
1456 }
1457 impl Drop for DropFlag {
1458 fn drop(&mut self) {
1459 self.zeroize();
1460 self.flag.store(true, Ordering::SeqCst);
1461 }
1462 }
1463
1464 let flag = Arc::new(AtomicBool::new(false));
1465 {
1466 let _df = DropFlag {
1467 flag: Arc::clone(&flag),
1468 bytes: [0xEFu8; 32],
1469 };
1470 assert!(!flag.load(Ordering::SeqCst));
1471 }
1472 assert!(
1473 flag.load(Ordering::SeqCst),
1474 "Drop impl on a SecretSeed-shaped type must run at scope exit"
1475 );
1476
1477 let kp = KeyPair::from_seed([0xDEu8; 32]);
1483 let preview = kp.secret.0[0];
1484 assert_eq!(preview, 0xDE);
1485 drop(kp);
1486 }
1487
1488 fn tempdir() -> std::path::PathBuf {
1493 use std::sync::atomic::{AtomicU64, Ordering};
1494 use std::time::{SystemTime, UNIX_EPOCH};
1495 static COUNTER: AtomicU64 = AtomicU64::new(0);
1496 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1497 let nanos = SystemTime::now()
1498 .duration_since(UNIX_EPOCH)
1499 .map_or(0, |d| d.as_nanos());
1500 let p =
1501 std::env::temp_dir().join(format!("mkit-sign-test-{nanos}-{n}-{}", std::process::id()));
1502 std::fs::create_dir_all(&p).unwrap();
1503 p
1504 }
1505}