use crate::hash::{HASH_LEN, Hash};
use crate::object::{Commit, Identity, MAGIC, MkitError, ObjectType, Remix, SCHEMA_VERSION, Tag};
use core::fmt;
use std::path::Path;
use ed25519_dalek::{
PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH, Signature as DalekSignature, Signer,
SigningKey, VerifyingKey,
};
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
#[cfg(unix)]
#[must_use]
pub fn effective_uid() -> u32 {
#[allow(unsafe_code)]
unsafe {
libc::geteuid()
}
}
pub const COMMIT_DOMAIN: &[u8] = b"mkit.commit\x00";
pub const REMIX_DOMAIN: &[u8] = b"mkit.remix\x00";
pub const TAG_DOMAIN: &[u8] = b"mkit.tag\x00";
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct PublicKey(pub [u8; PUBLIC_KEY_LENGTH]);
impl fmt::Debug for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("PublicKey").field(&"…").finish()
}
}
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct SecretSeed(pub [u8; SECRET_KEY_LENGTH]);
impl fmt::Debug for SecretSeed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("SecretSeed").field(&"<redacted>").finish()
}
}
impl PartialEq for SecretSeed {
fn eq(&self, other: &Self) -> bool {
bool::from(self.0.ct_eq(&other.0))
}
}
impl Eq for SecretSeed {}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct Signature(pub [u8; SIGNATURE_LENGTH]);
impl fmt::Debug for Signature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("Signature").field(&"…").finish()
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct KeyPair {
pub public: PublicKey,
pub secret: SecretSeed,
}
impl KeyPair {
pub fn generate() -> Result<Self, MkitError> {
let mut seed: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
getrandom::fill(seed.as_mut_slice()).map_err(|_| MkitError::RngFailure)?;
Ok(Self::from_seed_zeroizing(&seed))
}
#[must_use]
pub fn from_seed(mut seed: [u8; SECRET_KEY_LENGTH]) -> Self {
let signing = SigningKey::from_bytes(&seed);
let public = PublicKey(signing.verifying_key().to_bytes());
let secret = SecretSeed(seed);
seed.zeroize();
Self { public, secret }
}
#[must_use]
pub fn from_seed_zeroizing(seed: &Zeroizing<[u8; SECRET_KEY_LENGTH]>) -> Self {
let signing = SigningKey::from_bytes(seed);
let public = PublicKey(signing.verifying_key().to_bytes());
let mut secret_bytes = [0u8; SECRET_KEY_LENGTH];
secret_bytes.copy_from_slice(seed.as_slice());
let secret = SecretSeed(secret_bytes);
secret_bytes.zeroize();
Self { public, secret }
}
#[must_use]
pub fn sign(&self, domain: &[u8], signing_bytes: &[u8]) -> Signature {
let digest = domain_digest(domain, signing_bytes);
let signing = SigningKey::from_bytes(&self.secret.0);
let sig = signing.sign(&digest);
Signature(sig.to_bytes())
}
}
pub fn verify(
public: &PublicKey,
domain: &[u8],
signing_bytes: &[u8],
sig: &Signature,
) -> Result<(), MkitError> {
let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
let dalek_sig = DalekSignature::from_bytes(&sig.0);
let digest = domain_digest(domain, signing_bytes);
vk.verify_strict(&digest, &dalek_sig)
.map_err(|_| MkitError::SignatureInvalid)
}
#[must_use]
fn domain_digest(domain: &[u8], signing_bytes: &[u8]) -> [u8; HASH_LEN] {
crate::hash::domain_digest(domain, signing_bytes)
}
pub fn commit_signing_hash(c: &Commit) -> Result<Hash, MkitError> {
let sb = commit_signing_bytes(c)?;
Ok(domain_digest(COMMIT_DOMAIN, &sb))
}
pub fn remix_signing_hash(r: &Remix) -> Result<Hash, MkitError> {
let sb = remix_signing_bytes(r)?;
Ok(domain_digest(REMIX_DOMAIN, &sb))
}
pub fn tag_signing_hash(t: &Tag) -> Result<Hash, MkitError> {
let sb = tag_signing_bytes(t)?;
Ok(domain_digest(TAG_DOMAIN, &sb))
}
fn write_prologue(buf: &mut Vec<u8>, t: ObjectType) {
buf.push(t as u8);
buf.extend_from_slice(&MAGIC);
buf.push(SCHEMA_VERSION);
}
fn write_identity(buf: &mut Vec<u8>, id: &Identity) -> Result<(), MkitError> {
if !id.is_valid() {
return Err(MkitError::InvalidIdentity);
}
buf.push(id.kind as u8);
let len = u16::try_from(id.bytes.len()).map_err(|_| MkitError::IdentityTooLarge)?;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&id.bytes);
Ok(())
}
pub fn commit_signing_bytes(c: &Commit) -> Result<Vec<u8>, MkitError> {
let mut buf = Vec::with_capacity(
6 + 32 + 4 + c.parents.len() * 32 + 3 + c.author.bytes.len() + 4 + c.message.len() + 8 + 32,
);
write_prologue(&mut buf, ObjectType::Commit);
buf.extend_from_slice(&c.tree_hash);
let parent_count = u32::try_from(c.parents.len()).map_err(|_| MkitError::TooManyParents)?;
buf.extend_from_slice(&parent_count.to_le_bytes());
for p in &c.parents {
buf.extend_from_slice(p);
}
write_identity(&mut buf, &c.author)?;
let mlen = u32::try_from(c.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
buf.extend_from_slice(&mlen.to_le_bytes());
buf.extend_from_slice(&c.message);
buf.extend_from_slice(&c.timestamp.to_le_bytes());
buf.extend_from_slice(&c.signer);
Ok(buf)
}
pub fn remix_signing_bytes(r: &Remix) -> Result<Vec<u8>, MkitError> {
let mut buf = Vec::with_capacity(
6 + 32
+ 4
+ r.parents.len() * 32
+ 4
+ r.sources.len() * 64
+ 3
+ r.author.bytes.len()
+ 4
+ r.message.len()
+ 8
+ 32,
);
write_prologue(&mut buf, ObjectType::Remix);
buf.extend_from_slice(&r.tree_hash);
let parent_count = u32::try_from(r.parents.len()).map_err(|_| MkitError::TooManyParents)?;
buf.extend_from_slice(&parent_count.to_le_bytes());
for p in &r.parents {
buf.extend_from_slice(p);
}
let source_count = u32::try_from(r.sources.len()).map_err(|_| MkitError::TooManySources)?;
buf.extend_from_slice(&source_count.to_le_bytes());
for s in &r.sources {
buf.extend_from_slice(&s.upstream_id);
buf.extend_from_slice(&s.commit_hash);
}
write_identity(&mut buf, &r.author)?;
let mlen = u32::try_from(r.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
buf.extend_from_slice(&mlen.to_le_bytes());
buf.extend_from_slice(&r.message);
buf.extend_from_slice(&r.timestamp.to_le_bytes());
buf.extend_from_slice(&r.signer);
Ok(buf)
}
pub fn tag_signing_bytes(t: &Tag) -> Result<Vec<u8>, MkitError> {
if !t.name_is_valid() {
return Err(MkitError::TagNameInvalid);
}
if matches!(t.target_type, ObjectType::Delta) {
return Err(MkitError::TagTargetTypeInvalid(t.target_type as u8));
}
let mut buf = Vec::with_capacity(
6 + 32 + 1 + 4 + t.name.len() + 3 + t.tagger.bytes.len() + 4 + t.message.len() + 8 + 32,
);
write_prologue(&mut buf, ObjectType::Tag);
buf.extend_from_slice(&t.target);
buf.push(t.target_type as u8);
let nlen = u32::try_from(t.name.len()).map_err(|_| MkitError::TagNameInvalid)?;
buf.extend_from_slice(&nlen.to_le_bytes());
buf.extend_from_slice(&t.name);
write_identity(&mut buf, &t.tagger)?;
let mlen = u32::try_from(t.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
buf.extend_from_slice(&mlen.to_le_bytes());
buf.extend_from_slice(&t.message);
buf.extend_from_slice(&t.timestamp.to_le_bytes());
buf.extend_from_slice(&t.signer);
Ok(buf)
}
pub fn sign_tag(t: &Tag, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = tag_signing_bytes(t)?;
Ok(kp.sign(TAG_DOMAIN, &sb))
}
pub fn verify_tag(t: &Tag) -> Result<(), MkitError> {
let sb = tag_signing_bytes(t)?;
let pk = PublicKey(t.signer);
let sig = Signature(t.signature);
verify(&pk, TAG_DOMAIN, &sb, &sig)
}
pub fn sign_commit(c: &Commit, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = commit_signing_bytes(c)?;
Ok(kp.sign(COMMIT_DOMAIN, &sb))
}
pub fn sign_remix(r: &Remix, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = remix_signing_bytes(r)?;
Ok(kp.sign(REMIX_DOMAIN, &sb))
}
pub fn verify_commit(c: &Commit) -> Result<(), MkitError> {
let sb = commit_signing_bytes(c)?;
let pk = PublicKey(c.signer);
let sig = Signature(c.signature);
verify(&pk, COMMIT_DOMAIN, &sb, &sig)
}
pub fn verify_remix(r: &Remix) -> Result<(), MkitError> {
let sb = remix_signing_bytes(r)?;
let pk = PublicKey(r.signer);
let sig = Signature(r.signature);
verify(&pk, REMIX_DOMAIN, &sb, &sig)
}
pub fn load_key(path: &Path) -> Result<KeyPair, MkitError> {
let seed = load_raw_32(path)?;
Ok(KeyPair::from_seed_zeroizing(&seed))
}
pub fn load_raw_32(path: &Path) -> Result<zeroize::Zeroizing<[u8; 32]>, MkitError> {
#[cfg(unix)]
{
use std::io::Read as _;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
ensure_no_symlink_ancestors(path)?;
let mut f = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|e| {
if e.raw_os_error() == Some(libc::ELOOP) {
MkitError::KeyPathIsSymlink(path.display().to_string())
} else {
MkitError::KeyIo(format!("open: {e}"))
}
})?;
let meta = f
.metadata()
.map_err(|e| MkitError::KeyIo(format!("fstat: {e}")))?;
let mode = meta.mode() & 0o777;
if mode & 0o077 != 0 {
return Err(MkitError::InsecureKeyPermissions { actual: mode });
}
let euid = effective_uid();
if meta.uid() != euid {
return Err(MkitError::InsecureKeyOwner {
actual: meta.uid(),
euid,
});
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
check_parent_dir_secure(parent)?;
}
let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
if let Err(e) = f.read_exact(seed.as_mut_slice()) {
return if e.kind() == std::io::ErrorKind::UnexpectedEof {
Err(MkitError::InvalidKeyLength {
actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
})
} else {
Err(MkitError::KeyIo(format!("read: {e}")))
};
}
let mut probe = [0u8; 1];
let trailing = f
.read(&mut probe)
.map_err(|e| MkitError::KeyIo(format!("read trailing byte: {e}")))?;
if trailing != 0 {
return Err(MkitError::InvalidKeyLength {
actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
});
}
Ok(seed)
}
#[cfg(not(unix))]
{
let raw = std::fs::read(path).map_err(|e| MkitError::KeyIo(format!("read: {e}")))?;
if raw.len() != SECRET_KEY_LENGTH {
return Err(MkitError::InvalidKeyLength { actual: raw.len() });
}
let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
seed.copy_from_slice(&raw);
let mut raw = raw;
raw.zeroize();
Ok(seed)
}
}
#[cfg(unix)]
fn check_parent_dir_secure(parent: &Path) -> Result<(), MkitError> {
use std::os::unix::fs::MetadataExt;
let Ok(meta) = std::fs::metadata(parent) else {
return Ok(());
};
let mode = meta.mode() & 0o777;
if mode & 0o077 != 0 {
return Err(MkitError::InsecureKeyDir { actual: mode });
}
Ok(())
}
#[cfg(unix)]
fn ensure_no_symlink_ancestors(path: &Path) -> Result<(), MkitError> {
let mut current = path.parent();
for _ in 0..3 {
let Some(dir) = current else {
break;
};
if dir.as_os_str().is_empty() {
break;
}
match std::fs::symlink_metadata(dir) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(MkitError::KeyPathIsSymlink(dir.display().to_string()));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(MkitError::KeyIo(format!("lstat {}: {e}", dir.display()))),
}
current = dir.parent();
}
if let Ok(meta) = std::fs::symlink_metadata(path)
&& meta.file_type().is_symlink()
{
return Err(MkitError::KeyPathIsSymlink(path.display().to_string()));
}
Ok(())
}
#[cfg(unix)]
fn create_secure_dir_all(parent: &Path) -> Result<(), MkitError> {
use std::os::unix::fs::PermissionsExt;
ensure_no_symlink_ancestors(parent)?;
std::fs::create_dir_all(parent)
.map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|e| MkitError::KeyIo(format!("chmod parent: {e}")))?;
Ok(())
}
pub fn save_key(path: &Path, kp: &KeyPair) -> Result<(), MkitError> {
save_raw_32(path, &kp.secret.0)
}
pub fn save_raw_32(path: &Path, secret: &[u8; 32]) -> Result<(), MkitError> {
let parent: &Path = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;
create_secure_dir_all(parent)?;
let filename = path
.file_name()
.ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
let tmp_name = {
let mut s = std::ffi::OsString::from(".");
s.push(filename);
s.push(format!(".tmp.{}", std::process::id()));
s
};
let tmp_path = parent.join(&tmp_name);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| MkitError::KeyIo(format!("open tmp {}: {e}", tmp_path.display())))?;
if let Err(e) = f.write_all(secret) {
let _ = std::fs::remove_file(&tmp_path);
return Err(MkitError::KeyIo(format!("write: {e}")));
}
if let Err(e) = f.sync_all() {
let _ = std::fs::remove_file(&tmp_path);
return Err(MkitError::KeyIo(format!("fsync tmp: {e}")));
}
drop(f);
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(MkitError::KeyIo(format!("rename: {e}")));
}
let dir = std::fs::File::open(parent)
.map_err(|e| MkitError::KeyIo(format!("open dir for fsync: {e}")))?;
dir.sync_all()
.map_err(|e| MkitError::KeyIo(format!("fsync dir: {e}")))?;
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(parent)
.map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
let filename = path
.file_name()
.ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
let mut tmp_name = std::ffi::OsString::from(".");
tmp_name.push(filename);
tmp_name.push(format!(".tmp.{}", std::process::id()));
let tmp_path = parent.join(&tmp_name);
std::fs::write(&tmp_path, secret)
.map_err(|e| MkitError::KeyIo(format!("write tmp: {e}")))?;
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(MkitError::KeyIo(format!("rename: {e}")));
}
}
Ok(())
}
pub fn save_raw_32_create_new(path: &Path, secret: &[u8; 32]) -> Result<bool, MkitError> {
let parent: &Path = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
#[cfg(unix)]
create_secure_dir_all(parent)?;
#[cfg(not(unix))]
std::fs::create_dir_all(parent)
.map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
crate::atomic::write_create_new(path, secret, false)
.map_err(|e| MkitError::KeyIo(format!("create key: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::{ZERO, hash};
use crate::object::{Identity, IdentityKind, ObjectType, RemixSource, Tag};
fn fixed_kp() -> KeyPair {
KeyPair::from_seed([0x42; 32])
}
fn ed25519_id(pk: [u8; 32]) -> Identity {
Identity {
kind: IdentityKind::Ed25519,
bytes: pk.to_vec(),
}
}
#[test]
fn sign_verify_roundtrip() {
let kp = fixed_kp();
let bytes = b"some signing bytes";
let sig = kp.sign(COMMIT_DOMAIN, bytes);
verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("verify ok");
}
#[test]
fn verify_rejects_tampered_input() {
let kp = fixed_kp();
let bytes = b"original".to_vec();
let sig = kp.sign(COMMIT_DOMAIN, &bytes);
let mut tampered = bytes.clone();
tampered[0] ^= 0x01;
assert!(matches!(
verify(&kp.public, COMMIT_DOMAIN, &tampered, &sig),
Err(MkitError::SignatureInvalid)
));
}
#[test]
fn verify_rejects_wrong_key() {
let kp1 = fixed_kp();
let kp2 = KeyPair::from_seed([0x55; 32]);
let bytes = b"x";
let sig = kp1.sign(COMMIT_DOMAIN, bytes);
assert!(matches!(
verify(&kp2.public, COMMIT_DOMAIN, bytes, &sig),
Err(MkitError::SignatureInvalid)
));
}
#[test]
fn our_signatures_pass_strict_verify() {
let kp = fixed_kp();
for (i, input) in [
b"" as &[u8],
b"a",
b"00000000000000000000000000000000",
&[0xff; 64],
&(0u8..=255).collect::<Vec<u8>>(),
]
.iter()
.enumerate()
{
let sig = kp.sign(COMMIT_DOMAIN, input);
verify(&kp.public, COMMIT_DOMAIN, input, &sig)
.unwrap_or_else(|e| panic!("input #{i} failed strict verify: {e:?}"));
}
}
#[test]
fn domain_separation_commit_vs_remix() {
let kp = fixed_kp();
let bytes = b"shared bytes";
let sig = kp.sign(COMMIT_DOMAIN, bytes);
assert!(matches!(
verify(&kp.public, REMIX_DOMAIN, bytes, &sig),
Err(MkitError::SignatureInvalid)
));
}
#[test]
fn domain_digest_differs_per_domain() {
let bytes = b"abc";
let a = domain_digest(COMMIT_DOMAIN, bytes);
let b = domain_digest(REMIX_DOMAIN, bytes);
assert_ne!(a, b);
}
#[test]
fn domain_digest_includes_length_prefix() {
let domain = b"ab";
let msg = b"cX";
let got = domain_digest(domain, msg);
let mut want = blake3::Hasher::new();
let len = u16::try_from(domain.len()).unwrap();
want.update(&len.to_le_bytes());
want.update(domain);
want.update(msg);
assert_eq!(got, *want.finalize().as_bytes());
let other = domain_digest(b"abc", b"X");
assert_ne!(got, other);
}
#[test]
fn ed25519_rfc8032_vector_1() {
let seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
let pk_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
let sig_hex = concat!(
"e5564300c360ac729086e2cc806e828a",
"84877f1eb8e5d974d873e06522490155",
"5fb8821590a33bacc61e39701cf9b46b",
"d25bf5f0595bbe24655141438e7a100b",
);
let seed: [u8; 32] = hex::decode(seed_hex).unwrap().try_into().unwrap();
let kp = KeyPair::from_seed(seed);
assert_eq!(hex::encode(kp.public.0), pk_hex);
let signing = SigningKey::from_bytes(&kp.secret.0);
let sig = signing.sign(b"");
assert_eq!(hex::encode(sig.to_bytes()), sig_hex);
}
fn build_commit(kp: &KeyPair, msg: &[u8]) -> Commit {
Commit {
tree_hash: hash(b"tree"),
parents: vec![],
author: ed25519_id(kp.public.0),
signer: kp.public.0,
message: msg.to_vec(),
timestamp: 1_711_300_000,
message_hash: ZERO,
content_digest: ZERO,
signature: [0u8; 64],
}
}
#[test]
fn sign_then_verify_commit() {
let kp = fixed_kp();
let mut c = build_commit(&kp, b"hello");
c.signature = sign_commit(&c, &kp).unwrap().0;
verify_commit(&c).expect("verify ok");
}
#[test]
fn tampered_commit_message_fails_verify() {
let kp = fixed_kp();
let mut c = build_commit(&kp, b"hello");
c.signature = sign_commit(&c, &kp).unwrap().0;
c.message = b"tampered".to_vec();
assert!(matches!(
verify_commit(&c),
Err(MkitError::SignatureInvalid)
));
}
#[test]
fn message_hash_does_not_affect_signing_bytes() {
let kp = fixed_kp();
let mut c1 = build_commit(&kp, b"x");
let mut c2 = c1.clone();
c2.message_hash = hash(b"some annotation");
c2.content_digest = hash(b"another annotation");
let sb1 = commit_signing_bytes(&c1).unwrap();
let sb2 = commit_signing_bytes(&c2).unwrap();
assert_eq!(sb1, sb2);
c1.signature = sign_commit(&c1, &kp).unwrap().0;
c2.signature = c1.signature;
verify_commit(&c2).expect("annotation fields are not signed");
}
#[test]
fn sign_then_verify_remix() {
let kp = fixed_kp();
let mut r = Remix {
tree_hash: hash(b"tree"),
parents: vec![],
sources: vec![RemixSource {
upstream_id: hash(b"upstream"),
commit_hash: hash(b"commit"),
}],
author: ed25519_id(kp.public.0),
signer: kp.public.0,
message: b"remix".to_vec(),
timestamp: 2_000,
signature: [0u8; 64],
};
r.signature = sign_remix(&r, &kp).unwrap().0;
verify_remix(&r).expect("verify ok");
}
fn build_tag(kp: &KeyPair, msg: &[u8]) -> Tag {
Tag {
target: hash(b"target"),
target_type: ObjectType::Commit,
name: b"v1.0.0".to_vec(),
tagger: ed25519_id(kp.public.0),
signer: kp.public.0,
message: msg.to_vec(),
timestamp: 1_711_300_000,
signature: [0u8; 64],
}
}
#[test]
fn sign_then_verify_tag() {
let kp = fixed_kp();
let mut t = build_tag(&kp, b"release");
t.signature = sign_tag(&t, &kp).unwrap().0;
verify_tag(&t).expect("verify ok");
}
#[test]
fn tampered_tag_message_fails_verify() {
let kp = fixed_kp();
let mut t = build_tag(&kp, b"release");
t.signature = sign_tag(&t, &kp).unwrap().0;
t.message = b"tampered".to_vec();
assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
}
#[test]
fn tampered_tag_target_fails_verify() {
let kp = fixed_kp();
let mut t = build_tag(&kp, b"release");
t.signature = sign_tag(&t, &kp).unwrap().0;
t.target = hash(b"other");
assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
}
#[test]
fn tag_domain_differs_from_commit_and_remix() {
assert_ne!(TAG_DOMAIN, COMMIT_DOMAIN);
assert_ne!(TAG_DOMAIN, REMIX_DOMAIN);
let bytes = b"abc";
let dt = domain_digest(TAG_DOMAIN, bytes);
assert_ne!(dt, domain_digest(COMMIT_DOMAIN, bytes));
assert_ne!(dt, domain_digest(REMIX_DOMAIN, bytes));
}
#[test]
fn tag_signature_does_not_verify_as_commit_or_remix() {
let kp = fixed_kp();
let bytes = b"shared signing bytes";
let tag_sig = kp.sign(TAG_DOMAIN, bytes);
assert!(matches!(
verify(&kp.public, COMMIT_DOMAIN, bytes, &tag_sig),
Err(MkitError::SignatureInvalid)
));
assert!(matches!(
verify(&kp.public, REMIX_DOMAIN, bytes, &tag_sig),
Err(MkitError::SignatureInvalid)
));
let commit_sig = kp.sign(COMMIT_DOMAIN, bytes);
assert!(matches!(
verify(&kp.public, TAG_DOMAIN, bytes, &commit_sig),
Err(MkitError::SignatureInvalid)
));
}
#[test]
fn signing_is_deterministic() {
let kp = fixed_kp();
let bytes = b"deterministic";
let s1 = kp.sign(COMMIT_DOMAIN, bytes);
let s2 = kp.sign(COMMIT_DOMAIN, bytes);
assert_eq!(s1.0, s2.0);
}
#[test]
fn save_then_load_roundtrip() {
let dir = tempdir();
let p = dir.join("default.key");
let kp = KeyPair::from_seed([0x77; 32]);
save_key(&p, &kp).unwrap();
let kp2 = load_key(&p).unwrap();
assert_eq!(kp.public.0, kp2.public.0);
assert_eq!(kp.secret.0, kp2.secret.0);
}
#[cfg(unix)]
#[test]
fn save_key_writes_mode_0600() {
use std::os::unix::fs::MetadataExt;
let dir = tempdir();
let p = dir.join("default.key");
let kp = KeyPair::from_seed([0x33; 32]);
save_key(&p, &kp).unwrap();
let meta = std::fs::metadata(&p).unwrap();
assert_eq!(meta.mode() & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn save_key_tightens_preexisting_wide_mode_to_0600() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let dir = tempdir();
let p = dir.join("default.key");
std::fs::write(&p, b"old contents").unwrap();
let mut perm = std::fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o644);
std::fs::set_permissions(&p, perm).unwrap();
assert_eq!(
std::fs::metadata(&p).unwrap().mode() & 0o777,
0o644,
"sanity: pre-seeded 0o644"
);
let kp = KeyPair::from_seed([0x55; 32]);
save_key(&p, &kp).unwrap();
let meta = std::fs::metadata(&p).unwrap();
assert_eq!(meta.mode() & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn load_key_rejects_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir();
let p = dir.join("default.key");
let kp = KeyPair::from_seed([0x33; 32]);
save_key(&p, &kp).unwrap();
let mut perm = std::fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o644);
std::fs::set_permissions(&p, perm).unwrap();
match load_key(&p) {
Err(MkitError::InsecureKeyPermissions { actual }) => {
assert_eq!(actual, 0o644);
}
other => panic!("expected InsecureKeyPermissions, got {other:?}"),
}
}
#[test]
fn load_key_rejects_wrong_length() {
let dir = tempdir();
let p = dir.join("short.key");
std::fs::write(&p, b"too short").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o600);
std::fs::set_permissions(&p, perm).unwrap();
let mut dperm = std::fs::metadata(&dir).unwrap().permissions();
dperm.set_mode(0o700);
std::fs::set_permissions(&dir, dperm).unwrap();
}
assert!(matches!(
load_key(&p),
Err(MkitError::InvalidKeyLength { actual: 9 })
));
}
#[cfg(unix)]
#[test]
fn load_key_rejects_symlink() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir();
let real = dir.join("real.key");
let kp = KeyPair::from_seed([0xAB; 32]);
save_key(&real, &kp).unwrap();
let link = dir.join("link.key");
std::os::unix::fs::symlink(&real, &link).unwrap();
let mut perm = std::fs::metadata(&dir).unwrap().permissions();
perm.set_mode(0o700);
std::fs::set_permissions(&dir, perm).unwrap();
match load_key(&link) {
Err(MkitError::KeyPathIsSymlink(_)) => {}
other => panic!("expected KeyPathIsSymlink, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn load_key_rejects_symlinked_ancestor() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir();
let real_parent = dir.join("realkeys");
std::fs::create_dir_all(&real_parent).unwrap();
let mut parent_perm = std::fs::metadata(&real_parent).unwrap().permissions();
parent_perm.set_mode(0o700);
std::fs::set_permissions(&real_parent, parent_perm).unwrap();
let real = real_parent.join("default.key");
let kp = KeyPair::from_seed([0xBC; 32]);
save_key(&real, &kp).unwrap();
let symlink_parent = dir.join("symlink-keys");
std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
match load_key(&symlink_parent.join("default.key")) {
Err(MkitError::KeyPathIsSymlink(_)) => {}
other => panic!("expected KeyPathIsSymlink, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn load_key_rejects_world_readable_parent() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir();
let p = dir.join("default.key");
let kp = KeyPair::from_seed([0xCD; 32]);
save_key(&p, &kp).unwrap();
let mut perm = std::fs::metadata(&dir).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&dir, perm).unwrap();
match load_key(&p) {
Err(MkitError::InsecureKeyDir { actual }) => {
assert_eq!(actual, 0o755);
}
other => panic!("expected InsecureKeyDir, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn save_key_replaces_existing_key_atomically() {
use std::os::unix::fs::MetadataExt;
let dir = tempdir();
let p = dir.join("default.key");
let kp1 = KeyPair::from_seed([0x11; 32]);
save_key(&p, &kp1).unwrap();
let inode_before = std::fs::metadata(&p).unwrap().ino();
let kp2 = KeyPair::from_seed([0x22; 32]);
save_key(&p, &kp2).unwrap();
let meta_after = std::fs::metadata(&p).unwrap();
assert_ne!(
meta_after.ino(),
inode_before,
"save_key must replace via rename, not truncate-in-place"
);
assert_eq!(meta_after.mode() & 0o777, 0o600);
let kp_loaded = load_key(&p).unwrap();
assert_eq!(kp_loaded.public.0, kp2.public.0);
}
#[test]
fn save_raw_32_create_new_refuses_existing_key() {
let dir = tempdir();
let p = dir.join("default.key");
assert!(save_raw_32_create_new(&p, &[0x11; 32]).unwrap());
assert!(!save_raw_32_create_new(&p, &[0x22; 32]).unwrap());
assert_eq!(&*load_raw_32(&p).unwrap(), &[0x11; 32]);
}
#[cfg(unix)]
#[test]
fn save_key_rejects_symlinked_ancestor() {
let dir = tempdir();
let real_parent = dir.join("realkeys");
std::fs::create_dir_all(&real_parent).unwrap();
let symlink_parent = dir.join("symlink-keys");
std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
let kp = KeyPair::from_seed([0x44; 32]);
match save_key(&symlink_parent.join("default.key"), &kp) {
Err(MkitError::KeyPathIsSymlink(_)) => {}
other => panic!("expected KeyPathIsSymlink, got {other:?}"),
}
}
#[test]
fn secret_seed_zeroize_clears_bytes() {
let mut s = SecretSeed([0xAAu8; SECRET_KEY_LENGTH]);
s.zeroize();
assert_eq!(s.0, [0u8; SECRET_KEY_LENGTH]);
}
#[test]
fn zeroizing_seed_scrubs_on_drop() {
use zeroize::Zeroize;
let mut s: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new([0xCDu8; SECRET_KEY_LENGTH]);
s.zeroize();
assert_eq!(*s, [0u8; SECRET_KEY_LENGTH]);
}
#[test]
fn from_seed_zeroizing_matches_from_seed() {
let raw = [0x9Au8; SECRET_KEY_LENGTH];
let wrapped: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new(raw);
let a = KeyPair::from_seed(raw);
let b = KeyPair::from_seed_zeroizing(&wrapped);
assert_eq!(a.public.0, b.public.0);
assert_eq!(a.secret.0, b.secret.0);
let sig = b.sign(COMMIT_DOMAIN, b"x");
verify(&b.public, COMMIT_DOMAIN, b"x", &sig).expect("verify");
}
#[test]
fn from_seed_scrubs_owned_param() {
let mut seed = [0x5Au8; SECRET_KEY_LENGTH];
let kp = KeyPair::from_seed(seed);
assert_ne!(kp.public.0, [0u8; 32], "public key derived");
seed.zeroize();
assert_eq!(seed, [0u8; SECRET_KEY_LENGTH], "owned seed scrubs to zero");
}
#[test]
fn keypair_drop_runs_zeroize_on_secret() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct DropFlag {
flag: Arc<AtomicBool>,
bytes: [u8; 32],
}
impl Zeroize for DropFlag {
fn zeroize(&mut self) {
self.bytes.zeroize();
}
}
impl Drop for DropFlag {
fn drop(&mut self) {
self.zeroize();
self.flag.store(true, Ordering::SeqCst);
}
}
let flag = Arc::new(AtomicBool::new(false));
{
let _df = DropFlag {
flag: Arc::clone(&flag),
bytes: [0xEFu8; 32],
};
assert!(!flag.load(Ordering::SeqCst));
}
assert!(
flag.load(Ordering::SeqCst),
"Drop impl on a SecretSeed-shaped type must run at scope exit"
);
let kp = KeyPair::from_seed([0xDEu8; 32]);
let preview = kp.secret.0[0];
assert_eq!(preview, 0xDE);
drop(kp);
}
fn tempdir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let p =
std::env::temp_dir().join(format!("mkit-sign-test-{nanos}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
}