sign.rs
This Kit feature is not available yet.
In rust/crates/mkit-core/src/
Preview
Code · 72.3 KiB//! Ed25519 commit / remix signing.
//!
//! Spec: `docs/specs/SPEC-SIGNING.md`. The exact bytes covered by an Ed25519
//! signature, and the domain separator used, are normative; this module
//! reproduces them byte-for-byte. The golden tests in
//! `tests/golden_sign.rs` pin the output.
//!
//! Briefly:
//!
//! * Algorithm: Ed25519 per RFC 8032, signing the **BLAKE3 digest** of
//! `domain || signing_bytes` (`PureEdDSA` over a pre-hashed message —
//! the digest itself is what is signed; we do *not* use Ed25519ph).
//! * Domain separator is byte-prepended to the signing bytes; the
//! trailing `\x00` is part of the domain (see SPEC §2).
//! * `commit_signing_bytes` and `remix_signing_bytes` deliberately
//! exclude `signature`, `message_hash`, and `content_digest` (commit
//! only) — see SPEC §3.
//!
//! Keys live on disk as the raw 32-byte Ed25519 seed at
//! `.mkit/keys/default.key`, mode 0600. Public-key derivation is
//! deterministic from the seed.
use crate::hash::{HASH_LEN, Hash};
use crate::object::{
Commit, Identity, MAGIC, MkitError, Object, 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};
/// Effective uid for Unix key-file owner checks.
#[cfg(unix)]
#[must_use]
pub fn effective_uid() -> u32 {
// SAFETY: `geteuid(2)` is a parameterless syscall that always succeeds,
// never reads or writes user memory, and is reentrant.
#[allow(unsafe_code)]
unsafe {
libc::geteuid()
}
}
/// Domain separator used when signing commit objects. The trailing
/// `\x00` is load-bearing — see `docs/specs/SPEC-SIGNING.md` §2. Twelve bytes.
pub const COMMIT_DOMAIN: &[u8] = b"mkit.commit\x00";
/// Domain separator used when signing remix objects. Eleven bytes
/// including the trailing `\x00`.
pub const REMIX_DOMAIN: &[u8] = b"mkit.remix\x00";
/// Domain separator used when signing annotated/signed tag objects
/// (issue #230). Nine bytes including the trailing `\x00`.
///
/// DELIBERATELY DISTINCT from [`COMMIT_DOMAIN`] / [`REMIX_DOMAIN`] so a
/// tag signature can never be replayed as a commit/remix signature, or
/// vice versa — see `docs/specs/SPEC-SIGNING.md` §2 and §4a.
pub const TAG_DOMAIN: &[u8] = b"mkit.tag\x00";
/// 32-byte Ed25519 public key.
#[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()
}
}
/// 32-byte Ed25519 *seed* (the private value we persist to disk).
///
/// This is **not** the expanded RFC 8032 secret key; it is the raw
/// 32-byte input to `SHA512(seed)` from which the scalar and prefix are
/// derived. We deliberately mirror what `.mkit/keys/default.key` stores.
///
/// The wrapped bytes are zeroed on drop.
#[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 {
/// Constant-time equality via [`subtle::ConstantTimeEq`]. The
/// previous hand-rolled XOR-OR loop was correct in practice but
/// LLVM is permitted to short-circuit such loops, so we delegate
/// to a primitive whose contract pins constant-time semantics.
fn eq(&self, other: &Self) -> bool {
bool::from(self.0.ct_eq(&other.0))
}
}
impl Eq for SecretSeed {}
/// 64-byte Ed25519 signature (R || s).
#[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()
}
}
/// Ed25519 keypair: seed plus the deterministically-derived public key.
#[derive(Debug, PartialEq, Eq)]
pub struct KeyPair {
pub public: PublicKey,
pub secret: SecretSeed,
}
impl KeyPair {
/// Generate a fresh keypair using the system CSPRNG (`getrandom`).
///
/// # Zeroization
///
/// The local seed lives inside a [`Zeroizing`] wrapper that scrubs
/// the buffer at end of scope, so the only remaining copy is the
/// one inside the returned `KeyPair` (zeroized on drop via
/// `SecretSeed`'s [`ZeroizeOnDrop`]).
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))
}
/// Reconstruct a keypair deterministically from a 32-byte seed.
/// Pure function: same seed always yields the same public key.
///
/// This is a **self-scrubbing convenience constructor**: it zeroes
/// the `seed` argument it owns before returning (see the body), so
/// the moved-in buffer never lingers. It is kept as a public,
/// ergonomic entry point for callers that already hold a bare
/// `[u8; 32]` (e.g. test vectors, golden fixtures, and downstream /
/// WASM consumers that decode a seed from their own format).
///
/// # Zeroization
///
/// The contract this constructor guarantees: the `[u8; 32]` *passed
/// by value into this function* is scrubbed before return. What it
/// CANNOT do is reach back and scrub a `Copy` the caller left on
/// *their own* stack — `[u8; 32]: Copy`, so the argument is a moved
/// copy of whatever the caller held. Callers that keep sensitive
/// seed material on their own frame MUST therefore either:
///
/// * Prefer [`KeyPair::from_seed_zeroizing`], which takes a
/// [`Zeroizing`]-wrapped reference and never creates a Copy on
/// the caller's frame (this is what ALL internal mkit signing-path
/// code uses — `generate`, `load_key`, the attest signer factory,
/// and the WASM bindings), or
/// * Wrap their seed in [`Zeroizing`] themselves, or
/// * `seed.zeroize()` the buffer after this call returns.
///
/// [`KeyPair::generate`] and [`load_key`] already use the
/// `Zeroizing` path internally; no production call site passes a
/// bare `[u8; 32]` here. The contract above is pinned by the
/// `from_seed_zeroizing_matches_from_seed`,
/// `secret_seed_zeroize_clears_bytes` and
/// `keypair_drop_runs_zeroize_on_secret` regression tests. (The
/// owned-argument scrub two lines below is unobservable from
/// outside the function and is therefore covered by review, not by
/// a test — a previous test only ever scrubbed its own local
/// copy.)
#[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());
// `[u8; 32]: Copy`, so moving `seed` into `SecretSeed` would
// leave the original stack slot live. Build the wrapper first,
// then scrub the parameter — `SecretSeed`'s `ZeroizeOnDrop`
// owns the only remaining copy.
let secret = SecretSeed(seed);
seed.zeroize();
Self { public, secret }
}
/// Reconstruct a keypair from a [`Zeroizing`]-wrapped 32-byte seed
/// without forcing the caller to keep a `Copy` of the raw bytes on
/// their own stack. This is the preferred constructor for
/// signing-path code that loads keys from disk (see [`load_key`])
/// or generates them on the fly (see [`KeyPair::generate`]).
///
/// # Zeroization
///
/// Borrowing the seed means this function never creates a fresh
/// `[u8; 32]` `Copy` on the caller's frame. The only memory copy
/// is the one owned by the returned `KeyPair::secret` field, which
/// zeroes on drop.
#[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());
// `**seed` would be a `Copy` of the inner array — to avoid
// that we initialise the destination zeroed and `copy_from_slice`
// through the borrow, so the inner array never materialises a
// second `Copy` on this frame.
let mut secret_bytes = [0u8; SECRET_KEY_LENGTH];
secret_bytes.copy_from_slice(seed.as_slice());
let secret = SecretSeed(secret_bytes);
// Scrub our local stack scratch even though we just moved the
// bytes into `SecretSeed` — the stack slot would otherwise
// retain the seed until the frame is reused.
secret_bytes.zeroize();
Self { public, secret }
}
/// Sign `signing_bytes` under the given domain. The actual Ed25519
/// input is `BLAKE3(len_le16(domain) || domain || signing_bytes)` — see
/// SPEC §2.2.
#[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())
}
}
/// Verify a signature over `BLAKE3(len_le16(domain) || domain || signing_bytes)`
/// against the embedded public key. Returns `Ok(())` on success.
///
/// Uses [`VerifyingKey::verify_strict`], which enforces ZIP-215 / RFC 8032
/// strict-verification semantics:
///
/// - The signature's `R` component is checked to be a canonical (i.e.
/// least-representation) encoding of a curve point — torsion-component
/// malleability is rejected.
/// - The signature's `s` component is checked to be canonical mod the
/// group order — high-s malleability is rejected.
/// - The public key's `A` component is checked to be canonical — small-
/// subgroup attacks are rejected.
///
/// The looser default `VerifyingKey::verify` accepts non-canonical
/// encodings for backwards compat with older Ed25519 implementations;
/// mkit has no such compat constraint (golden vectors are regenerated
/// from our own signer) so we hold the stricter line.
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)
}
/// Batch-verify many Ed25519 signatures over pre-computed digests in one
/// pass. Each entry is `(public_key, digest, sig)`, where `digest` is
/// exactly what [`verify`] computes internally — `BLAKE3(len_le16(domain)
/// || domain || signing_bytes)` — so callers typically supply
/// [`commit_signing_hash`]/[`remix_signing_hash`]/[`tag_signing_hash`]'s
/// output directly. Returns `Ok(())` only if every signature is valid.
///
/// Uses [`ed25519_dalek::verify_batch`]'s randomized-coefficient
/// multiscalar-multiplication equation: verifying `n` signatures this way
/// costs roughly one multiscalar multiplication over `2n + 1` points
/// instead of `n` independent double-scalar multiplications, which is
/// strictly less total work than calling [`verify`] `n` times — the
/// speedup [`ed25519_dalek`]'s own benchmarks put at 20-30%+ over
/// individually-verified batches of 4 and up. The trade-off: on failure
/// this only proves *at least one* entry is invalid, never which one —
/// callers that need to attribute the failure to a specific entry (mkit
/// does, to report which fetched object was unsigned/forged) must fall
/// back to calling [`verify`] (or [`verify_commit`]/[`verify_remix`]/
/// [`verify_tag`]) per entry on `Err`.
///
/// `verify_batch` itself performs neither of `verify_strict`'s two extra
/// malleability checks on its own — by the batch equation's own design
/// (see `ed25519-dalek`'s README §"Malleability"), folding per-entry
/// checks into the batch multiscalar multiplication would defeat the
/// point of a high-throughput check, so the crate deliberately leaves
/// them out and documents `VerifyingKey::is_weak` as the caller's
/// responsibility (its README covers only the public-key half; the
/// signature-`R` half is the same class of check, just undocumented as
/// a batch caveat). This function performs both itself, per entry,
/// before running the batch equation:
///
/// - `VerifyingKey::is_weak` — a weak (small-order) *public key* can
/// produce a signature valid for nearly any message, so a batch
/// containing one could accept a forgery `verify_strict` would reject.
/// - a small-order *signature `R`* — checked directly here via
/// `curve25519-dalek` (`CompressedEdwardsY::decompress` +
/// `EdwardsPoint::is_small_order`, the same check `verify_strict`
/// performs internally), matching `verify_strict`'s stated defense
/// against "torsion-component malleability" for `R` (see [`verify`]'s
/// doc comment) rather than leaving that half unchecked.
///
/// Confirmed by direct experiment against `ed25519-dalek` (not just
/// from its docs) that this second check is real, not redundant: taking
/// a genuine signature from a normal (non-weak) key and replacing its
/// `R` with `R + T` for a nonzero 8-torsion point `T` is rejected by
/// `ed25519-dalek`'s own loose `verify` too — its cofactorless
/// recompute-and-compare of `R` can't be fooled by adding a torsion
/// component this way, so that specific construction isn't a live
/// forgery path against `verify_batch` either. What the added check
/// closes is the one small-order value *within* the base subgroup —
/// `R = identity` — which uniquely can satisfy the equation without
/// tripping the "doesn't decompress to the base subgroup" failure the
/// experiment hit (at the cost of the signer's own private key to
/// compute a matching `s`; the resulting signature is an alternate
/// valid *encoding* for a message that key already authorized, not an
/// unauthorized forgery). Kept regardless: it costs one cheap
/// decompress-and-check per entry, and it makes this function's accept
/// set a true subset of `verify`'s rather than a documented
/// approximation of one — the kind of gap this codebase does not leave
/// open once it's identified (see the CHANGELOG's own history of this
/// exact "close the residual gap even when the win is small" call).
#[cfg(feature = "batch-verify")]
pub fn verify_batch(entries: &[(PublicKey, Hash, Signature)]) -> Result<(), MkitError> {
if entries.is_empty() {
return Ok(());
}
let mut verifying_keys = Vec::with_capacity(entries.len());
for (public, _, sig) in entries {
let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
if vk.is_weak() {
return Err(MkitError::SignatureInvalid);
}
let dalek_sig = DalekSignature::from_bytes(&sig.0);
let r_is_small_order = curve25519_dalek::edwards::CompressedEdwardsY(*dalek_sig.r_bytes())
.decompress()
.is_none_or(|r| r.is_small_order());
if r_is_small_order {
return Err(MkitError::SignatureInvalid);
}
verifying_keys.push(vk);
}
let messages: Vec<&[u8]> = entries
.iter()
.map(|(_, digest, _)| digest.as_slice())
.collect();
let signatures: Vec<DalekSignature> = entries
.iter()
.map(|(_, _, sig)| DalekSignature::from_bytes(&sig.0))
.collect();
ed25519_dalek::verify_batch(&messages, &signatures, &verifying_keys)
.map_err(|_| MkitError::SignatureInvalid)
}
// -------------------------------------------------------------------
// Signing-bytes builders
// -------------------------------------------------------------------
/// Compute `BLAKE3(len_le16(domain) || domain || signing_bytes)`.
/// Always 32 bytes.
///
/// Thin wrapper around the public [`crate::hash::domain_digest`].
/// Kept as a module-private alias because the original v0.1.0
/// signature scheme is golden-vector-pinned through this symbol; the
/// public hoist (Reuse B2) added the same routine to `mkit_core::hash`
/// for re-use by `sparse` etc., but the sign-path call sites
/// deliberately retain this local indirection so a future refactor of
/// the public function can't silently change signature output.
///
/// The 2-byte little-endian length prefix closes a latent ambiguity
/// that `BLAKE3(domain || signing_bytes)` alone would carry: without
/// a length prefix, the concatenation `domain || signing_bytes` is
/// not uniquely parseable back into its two halves. Two distinct
/// `(domain, signing_bytes)` pairs could in principle produce the
/// same input to the hash (e.g. `("ab", "cX")` vs `("abc", "X")`).
///
/// Domain strings are fixed constants (`COMMIT_DOMAIN` /
/// `REMIX_DOMAIN`) and always fit in `u16`; construction-time asserts
/// this in `sign()` / `verify()` call sites.
///
/// NOTE: This is a wire/signature change vs the original v0.1.0
/// format. Any signatures produced before this change do NOT verify
/// under the new digest. A coordinated CHANGELOG entry documents the
/// break; there are no shipped artefacts to migrate.
#[must_use]
fn domain_digest(domain: &[u8], signing_bytes: &[u8]) -> [u8; HASH_LEN] {
crate::hash::domain_digest(domain, signing_bytes)
}
/// Public helper:
/// `BLAKE3(len_le16(COMMIT_DOMAIN) || COMMIT_DOMAIN || commit_signing_bytes(c))`.
pub fn commit_signing_hash(c: &Commit) -> Result<Hash, MkitError> {
let sb = commit_signing_bytes(c)?;
Ok(domain_digest(COMMIT_DOMAIN, &sb))
}
/// Public helper:
/// `BLAKE3(len_le16(REMIX_DOMAIN) || REMIX_DOMAIN || remix_signing_bytes(r))`.
pub fn remix_signing_hash(r: &Remix) -> Result<Hash, MkitError> {
let sb = remix_signing_bytes(r)?;
Ok(domain_digest(REMIX_DOMAIN, &sb))
}
/// Public helper:
/// `BLAKE3(len_le16(TAG_DOMAIN) || TAG_DOMAIN || tag_signing_bytes(t))`.
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(())
}
/// Serialize a commit's fields for signing. SPEC-SIGNING §3.
///
/// INCLUDED, in order:
/// 1. Object prologue: `[type=0x03][magic="MKT1"][schema_version=0x01]`.
/// 2. `tree_hash` (32).
/// 3. `parent_count` (u32 LE) and `parent_hash` × `parent_count` (32 each).
/// 4. Identity author: `[kind:u8][len:u16 LE][payload:len]`.
/// 5. `message_len` (u32 LE) and message bytes.
/// 6. `timestamp` (u64 LE).
/// 7. `signer` (32).
///
/// EXCLUDED: `signature`, `message_hash`, `content_digest`.
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)
}
/// Serialize a remix's fields for signing. SPEC-SIGNING §4. Same shape
/// as commit, with `source_count || sources` between parents and author.
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)
}
/// Serialize a tag's fields for signing. SPEC-SIGNING §4a.
///
/// INCLUDED, in order:
/// 1. Object prologue: `[type=0x07][magic="MKT1"][schema_version=0x01]`.
/// 2. `target` (32) and `target_type` (u8).
/// 3. `name`: `[len:u32 LE][name bytes]`.
/// 4. Identity tagger: `[kind:u8][len:u16 LE][payload:len]`.
/// 5. `message`: `[len:u32 LE][message bytes]`.
/// 6. `timestamp` (u64 LE).
/// 7. `signer` (32).
///
/// EXCLUDED: `signature` (a signature cannot cover itself).
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)
}
/// Sign a tag object.
pub fn sign_tag(t: &Tag, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = tag_signing_bytes(t)?;
Ok(kp.sign(TAG_DOMAIN, &sb))
}
/// Verify a tag against the public key embedded in `t.signer`.
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)
}
/// Sign a commit object. Returns the 64-byte signature.
pub fn sign_commit(c: &Commit, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = commit_signing_bytes(c)?;
Ok(kp.sign(COMMIT_DOMAIN, &sb))
}
/// Sign a remix object.
pub fn sign_remix(r: &Remix, kp: &KeyPair) -> Result<Signature, MkitError> {
let sb = remix_signing_bytes(r)?;
Ok(kp.sign(REMIX_DOMAIN, &sb))
}
/// Verify a commit against the public key embedded in `c.signer`.
///
/// Returns `Ok(())` on success. Note: this does *not* check whether
/// `c.author`'s payload matches `c.signer` — that is an application
/// policy decision (see SPEC §6).
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)
}
/// Verify a remix against the public key embedded in `r.signer`.
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)
}
/// Verify the signature a signed object carries: [`verify_commit`],
/// [`verify_remix`] or [`verify_tag`] by type. `Blob`, `Tree`,
/// `ChunkedBlob` and `Delta` carry no signature and return `Ok(())`.
///
/// The one per-object signature dispatch shared by push verification
/// (`mkit_core::verify::verify_push`) and the CLI's fetch-side check.
///
/// # Errors
///
/// The underlying `verify_*` error for a commit, remix or tag whose
/// signature does not verify under its embedded `signer`.
pub fn verify_object_signature(obj: &Object) -> Result<(), MkitError> {
match obj {
Object::Commit(c) => verify_commit(c),
Object::Remix(r) => verify_remix(r),
Object::Tag(t) => verify_tag(t),
Object::Blob(_) | Object::Tree(_) | Object::ChunkedBlob(_) | Object::Delta(_) => Ok(()),
}
}
// -------------------------------------------------------------------
// Key file I/O — `.mkit/keys/default.key`
// -------------------------------------------------------------------
//
// The on-disk contract (SPEC-SIGNING §7):
// - Path: caller-provided, conventionally `<repo>/.mkit/keys/default.key`.
// - Contents: raw 32-byte Ed25519 seed (NOT the expanded secret key).
// - Permissions: file 0600, parent directory 0700.
// - Owner: must equal the calling process's effective uid.
// - Symlink-resistant: open(2) uses `O_NOFOLLOW`; both the file and
// any path component above it are refused if they're symlinks.
// - Crash-atomic write: tmp + fsync + rename + dir-fsync.
/// Load a keypair from `path`. Enforces the full disk contract above.
///
/// On non-Unix hosts the symlink/owner/mode checks are a no-op;
/// callers should keep keys under `%USERPROFILE%` and rely on default
/// ACLs (documented in `docs/specs/SPEC-SIGNING.md` §7).
pub fn load_key(path: &Path) -> Result<KeyPair, MkitError> {
let seed = load_raw_32(path)?;
// Borrowing through `from_seed_zeroizing` avoids the `*seed` Copy
// the older path would synthesise on this frame.
Ok(KeyPair::from_seed_zeroizing(&seed))
}
/// Load a raw 32-byte secret from `path` with the same Unix hardening
/// `load_key` uses for the Ed25519 seed path.
///
/// On Unix this rejects symlinks both at the final component and in any
/// existing ancestor above it.
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}"))
}
})?;
// fstat the open handle, NOT the path — closes the TOCTOU
// window in which an attacker could rename(2) a hostile inode
// into place between a path-based `metadata()` and `read()`.
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 });
}
// SAFETY: `geteuid(2)` is a parameterless syscall that always
// succeeds, never reads or writes user memory, and is reentrant
// and async-signal-safe per POSIX. This is one of two reviewed
// `unsafe` blocks in `mkit-core` (the other being the
// `F_BARRIERFSYNC` fcntl in `batch.rs`); the crate keeps
// `deny(unsafe_code)` so each opt-out is reviewable.
let euid = effective_uid();
if meta.uid() != euid {
return Err(MkitError::InsecureKeyOwner {
actual: meta.uid(),
euid,
});
}
// Refuse to load when the parent directory itself is loose.
// We only check the immediate parent — auditing every
// ancestor is policy that belongs in the install/setup flow,
// not in every signing call.
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}")))
};
}
// Reject longer files: we read 32 bytes, so anything left over
// is junk that almost certainly means the file isn't a valid
// mkit seed.
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;
// If the parent doesn't exist, defer to the file open above which
// will already have failed; not our error to report.
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(())
}
/// Persist a keypair to `path` as the raw 32-byte seed.
///
/// **Atomicity contract.** The write is crash-safe:
///
/// 1. Ensure the parent directory exists at mode 0700.
/// 2. Write the seed to a uniquely-named tmp file in the same
/// directory using `O_CREAT | O_EXCL | O_NOFOLLOW` and mode 0600.
/// `O_EXCL` defeats a pre-created symlink at the tmp name; same
/// directory ensures `rename(2)` is atomic on the same filesystem.
/// 3. `fsync` the tmp file's data to disk.
/// 4. `rename(2)` the tmp file to the final path. Replaces an
/// existing key atomically; never leaves a half-written final.
/// 5. `fsync` the parent directory so the rename itself is durable.
///
/// On Windows the file is written with default ACLs; users should keep
/// `.mkit/keys/` inside `%USERPROFILE%` (SPEC-SIGNING §7).
pub fn save_key(path: &Path, kp: &KeyPair) -> Result<(), MkitError> {
save_raw_32(path, &kp.secret.0)
}
/// Persist a raw 32-byte secret to `path` crash-atomically.
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())))?;
// `.<name>.tmp.<pid>` is unique enough across concurrent
// `keygen` runs and within the same dir guarantees rename is
// atomic.
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);
// Open with O_CREAT|O_EXCL|O_NOFOLLOW + mode 0600.
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 the file handle before rename; some filesystems are
// happier this way.
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}")));
}
// fsync the directory so the rename is durable across power
// loss. Failing this isn't a security issue (the previous
// committed state still verifies); it's a durability one.
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())))?;
// Windows path: write to a tmp file in the same directory, then
// rename. No POSIX permission knobs; the user-profile ACL is
// the only protection.
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(())
}
/// Persist a raw 32-byte secret only if `path` does not already exist.
///
/// Returns `Ok(true)` when the key was created and `Ok(false)` when the
/// destination already existed. The successful write path is crash-atomic and
/// preserves the same parent-directory hardening as [`save_raw_32`].
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}")))
}
// -------------------------------------------------------------------
// Tests
// -------------------------------------------------------------------
#[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(),
}
}
// ------------------------------------------------------------------
// Sign/verify roundtrip and tamper detection
// ------------------------------------------------------------------
#[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_object_signature_dispatches_by_type() {
use crate::object::{Blob, Commit, Identity, Object};
let kp = fixed_kp();
let mut commit = Commit {
tree_hash: [1u8; 32],
parents: vec![],
author: Identity::ed25519(kp.public.0),
signer: kp.public.0,
message: b"m".to_vec(),
timestamp: 1,
message_hash: [0u8; 32],
content_digest: [0u8; 32],
signature: [0u8; 64],
};
commit.signature = sign_commit(&commit, &kp).expect("sign").0;
verify_object_signature(&Object::Commit(commit.clone())).expect("signed commit");
commit.message = b"tampered".to_vec();
assert!(matches!(
verify_object_signature(&Object::Commit(commit)),
Err(MkitError::SignatureInvalid)
));
verify_object_signature(&Object::Blob(Blob { data: vec![] })).expect("unsigned kind");
}
#[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)
));
}
/// Regression guard on strict-verification compliance.
///
/// Our `verify()` uses [`ed25519_dalek::VerifyingKey::verify_strict`],
/// which rejects the Ed25519 malleability vectors documented at
/// <https://hdevalence.ca/blog/2020-10-04-its-25519am>. This test
/// asserts that signatures produced by our own signer pass the
/// strict check — if `ed25519-dalek` ever starts producing
/// non-canonical `R` or high-`s` signatures, this fails first.
#[test]
fn our_signatures_pass_strict_verify() {
let kp = fixed_kp();
// Sample the signature space across several different inputs;
// a subtle drift in `s` normalization (e.g. if the underlying
// crate changed its reduction strategy) would surface on at
// least one of these.
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:?}"));
}
}
/// A crafted, known non-canonical signature: take a signature our
/// own signer produced and replace its `s` component with `s + L`
/// (`L` = the Ed25519 base-point order). `s + L` is congruent to
/// the original `s` modulo `L` — the same scalar the verification
/// equation cares about — but is not the canonical
/// least-representative encoding RFC 8032 / `verify_strict`
/// requires. This is the classic Ed25519 high-`s` malleability
/// vector (<https://hdevalence.ca/blog/2020-10-04-its-25519am>),
/// constructed deterministically rather than by mutating a random
/// byte of an otherwise-valid signature.
#[test]
fn verify_rejects_non_canonical_high_s_signature() {
// L = 2^252 + 27742317777372353535851937790883648493, little-endian.
const L: [u8; 32] = [
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9,
0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10,
];
let kp = fixed_kp();
let bytes = b"malleability test payload";
let sig = kp.sign(COMMIT_DOMAIN, bytes);
verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("original signature must verify");
// s_malleated = s + L, computed as a 256-bit little-endian
// addition (s < L < 2^253, so s + L < 2^254 — no overflow past
// 32 bytes).
let mut malleated = sig.0;
let mut carry: u16 = 0;
for i in 0..32 {
let sum = u16::from(malleated[32 + i]) + u16::from(L[i]) + carry;
malleated[32 + i] = (sum & 0xFF) as u8;
carry = sum >> 8;
}
assert_eq!(carry, 0, "s + L must not overflow 32 bytes");
assert_ne!(
malleated[32..],
sig.0[32..],
"the malleated signature must differ from the original"
);
let bad_sig = Signature(malleated);
assert!(matches!(
verify(&kp.public, COMMIT_DOMAIN, bytes, &bad_sig),
Err(MkitError::SignatureInvalid)
));
}
// ------------------------------------------------------------------
// Batch verification (`verify_batch`)
// ------------------------------------------------------------------
#[cfg(feature = "batch-verify")]
#[test]
fn verify_batch_accepts_many_valid_signatures_under_distinct_keys() {
let entries: Vec<(PublicKey, Hash, Signature)> = (0..40u8)
.map(|i| {
let kp = KeyPair::from_seed([i; 32]);
let msg = format!("batch entry #{i}");
let digest = domain_digest(COMMIT_DOMAIN, msg.as_bytes());
let sig = kp.sign(COMMIT_DOMAIN, msg.as_bytes());
(kp.public, digest, sig)
})
.collect();
verify_batch(&entries).expect("every entry is validly signed");
}
#[cfg(feature = "batch-verify")]
#[test]
fn verify_batch_empty_is_ok() {
verify_batch(&[]).expect("an empty batch trivially verifies");
}
#[cfg(feature = "batch-verify")]
#[test]
fn verify_batch_rejects_one_tampered_signature_among_many_valid() {
let mut entries: Vec<(PublicKey, Hash, Signature)> = (0..16u8)
.map(|i| {
let kp = KeyPair::from_seed([i; 32]);
let msg = format!("batch entry #{i}");
let digest = domain_digest(COMMIT_DOMAIN, msg.as_bytes());
let sig = kp.sign(COMMIT_DOMAIN, msg.as_bytes());
(kp.public, digest, sig)
})
.collect();
// Every entry individually verifies (against its digest,
// matching what `verify` checks internally) before tampering.
for (pk, digest, sig) in &entries {
let vk = VerifyingKey::from_bytes(&pk.0).unwrap();
let dalek_sig = DalekSignature::from_bytes(&sig.0);
vk.verify_strict(digest, &dalek_sig)
.expect("fixture entry must verify before tampering");
}
entries[8].2.0[0] ^= 0xff;
let err = verify_batch(&entries).expect_err("a tampered entry must fail the batch");
assert!(matches!(err, MkitError::SignatureInvalid));
}
/// A genuine weak-key forgery, not just a mismatched signature: with
/// `A` (the public key) equal to the curve's neutral element, the
/// verification equation `[s]B == R + [k]A'` degenerates to `[s]B ==
/// R` for every `k` (since `[k]A' = [k]·0 = 0` regardless of `k`), so
/// `R = identity, s = 0` satisfies it for *any* message — no secret
/// key involved. `VerifyingKey::verify` (loose) accepts this for
/// arbitrary messages; only the weak-key check `verify_strict` (and
/// this module's `verify_batch`) adds is what rejects it. Confirmed
/// against `ed25519_dalek::VerifyingKey` directly (loose `verify`
/// accepts, `verify_strict` rejects) before relying on it here, so
/// this test is exercising `verify_batch`'s own weak-key guard
/// specifically — not incidentally failing for an unrelated reason
/// the way pairing the identity key with an arbitrary *valid*
/// signature (from a different key) would.
#[cfg(feature = "batch-verify")]
#[test]
fn verify_batch_rejects_a_weak_public_key_forgery() {
let mut pk_bytes = [0u8; 32];
pk_bytes[0] = 1; // compressed identity point (the neutral element)
let identity_pk = PublicKey(pk_bytes);
assert!(
VerifyingKey::from_bytes(&identity_pk.0)
.expect("identity point decodes")
.is_weak(),
"the compressed identity point must be flagged weak"
);
let mut sig_bytes = [0u8; 64];
sig_bytes[0] = 1; // R = identity; s = 0 (remaining bytes already zero)
let forged_sig = Signature(sig_bytes);
let digest = domain_digest(COMMIT_DOMAIN, b"any message at all, no key needed");
let entries = vec![(identity_pk, digest, forged_sig)];
let err = verify_batch(&entries).expect_err("a weak-key forgery must be rejected");
assert!(matches!(err, MkitError::SignatureInvalid));
}
/// Isolates the small-order-`R` check from the weak-public-key check
/// above: a genuinely *normal* (non-weak) key's real secret scalar
/// is used to solve `[s]B = R + [k]A` for `R = identity` — the one
/// small-order point that also lies in the base subgroup, so it's
/// the only one this equation can be solved for at all (every other
/// small-order `R` can't, per `verify_batch`'s doc comment; that's
/// also why this construction needs the real secret key, unlike an
/// outside forger who doesn't have it — see the same doc comment).
///
/// Confirms the construction is load-bearing, not just malformed
/// input: loose `verify` (no `R`/weak-key check) accepts it — so the
/// batch equation, which performs the identical check, would too —
/// while `verify_strict` and this module's `verify_batch` both
/// reject it via their small-order checks.
#[cfg(feature = "batch-verify")]
#[test]
fn verify_batch_rejects_a_small_order_r_forgery_from_a_normal_key() {
use curve25519_dalek::scalar::Scalar;
use ed25519_dalek::Verifier as _;
use ed25519_dalek::hazmat::ExpandedSecretKey;
use sha2::{Digest as _, Sha512};
let kp = fixed_kp();
let vk = VerifyingKey::from_bytes(&kp.public.0).unwrap();
assert!(!vk.is_weak(), "sanity: this key must not be weak");
// Expand the secret scalar exactly as real signing does (RFC
// 8032 §5.1.5): a = clamp(SHA-512(seed))[0..32].
let expanded_hash: [u8; 64] = Sha512::digest(kp.secret.0).into();
let esk = ExpandedSecretKey::from_bytes(&expanded_hash);
let mut r_bytes = [0u8; 32];
r_bytes[0] = 1; // R = compressed identity
let digest = domain_digest(COMMIT_DOMAIN, b"small-order-R forgery probe");
// k = H(R || A || digest) mod L — the same hash-to-scalar step
// `verify`/`verify_batch` both perform internally.
let mut h = Sha512::new();
h.update(r_bytes);
h.update(kp.public.0);
h.update(digest);
let k_bytes: [u8; 64] = h.finalize().into();
let k = Scalar::from_bytes_mod_order_wide(&k_bytes);
// s = k*a solves [s]B = [k]A = R + [k]A, since R = identity = [0]B.
let s = k * esk.scalar;
let mut sig_bytes = [0u8; 64];
sig_bytes[..32].copy_from_slice(&r_bytes);
sig_bytes[32..].copy_from_slice(s.as_bytes());
let forged_sig = DalekSignature::from_bytes(&sig_bytes);
vk.verify(&digest, &forged_sig)
.expect("forged signature must satisfy the loose verification equation");
assert!(
vk.verify_strict(&digest, &forged_sig).is_err(),
"verify_strict must reject the small-order R"
);
let entries = vec![(kp.public, digest, Signature(sig_bytes))];
let err = verify_batch(&entries).expect_err("a small-order-R forgery must be rejected");
assert!(matches!(err, MkitError::SignatureInvalid));
}
// ------------------------------------------------------------------
// Domain separation guard (SPEC §2.1)
// ------------------------------------------------------------------
#[test]
fn domain_separation_commit_vs_remix() {
let kp = fixed_kp();
let bytes = b"shared bytes";
let sig = kp.sign(COMMIT_DOMAIN, bytes);
// Same bytes, same key, but the wrong domain → MUST fail.
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);
}
/// `domain_digest` hashes a 2-byte LE length prefix before the
/// domain label, so
/// `BLAKE3(len_le16(D) || D || M)` — not `BLAKE3(D || M)`. This
/// closes a latent ambiguity between a domain `"ab"` + message
/// `"cX"` vs domain `"abc"` + message `"X"` (same concatenation).
///
/// Regression guard: compute the expected digest by hand and
/// assert it matches. If anyone reverts the length prefix this
/// trips before any golden-vector drift is noticed.
#[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());
// And the ambiguous case with different domain/message split
// MUST produce a different digest.
let other = domain_digest(b"abc", b"X");
assert_ne!(got, other);
}
// ------------------------------------------------------------------
// RFC 8032 §7.1 known-answer test 1.
//
// The reference vector covers a *raw* empty message. PureEdDSA over
// an empty input should produce the published signature. Since the
// only thing our `sign()` API exposes is "domain || signing_bytes",
// the simplest way to reproduce the vector is to call into the
// dalek `SigningKey` directly. This guards against any accidental
// change to the underlying primitive (e.g. someone swapping in
// Ed25519ph would silently break interop).
// ------------------------------------------------------------------
#[test]
fn ed25519_rfc8032_vector_1() {
// Test vector 1 from RFC 8032 §7.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);
// Round-trip the public key.
assert_eq!(hex::encode(kp.public.0), pk_hex);
// Sign the empty message — bypass our domain-prefix path so the
// test reads as the RFC vector.
let signing = SigningKey::from_bytes(&kp.secret.0);
let sig = signing.sign(b"");
assert_eq!(hex::encode(sig.to_bytes()), sig_hex);
}
// ------------------------------------------------------------------
// Commit / remix sign + verify (uses the full pipeline).
// ------------------------------------------------------------------
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() {
// Spec §3: `message_hash` and `content_digest` are EXCLUDED from
// the signing bytes. Two commits differing only in those fields
// must have identical signing bytes (and therefore identical
// signatures under the same key).
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");
}
// ------------------------------------------------------------------
// Tag sign + verify + cross-protocol domain separation.
// ------------------------------------------------------------------
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 annotated_unsigned_tag_fails_verify() {
// A tag carrying a full, otherwise-legitimate annotation body
// (name, tagger, message, timestamp) but an all-zero signature
// — the sentinel git-bridge import produces for an
// annotated-but-unsigned git tag — must never verify. The
// all-zero bytes are not a magic "no signature" marker; they
// are just another (invalid) signature value.
let kp = fixed_kp();
let mut t = build_tag(&kp, b"release");
t.signature = [0u8; 64];
assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
}
#[test]
fn tag_domain_differs_from_commit_and_remix() {
// The three domains must be pairwise distinct constants.
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));
}
/// Cross-protocol replay guard: a signature produced over the tag
/// domain MUST NOT verify under the commit/remix domain (and vice
/// versa), even with the same key and the same signing 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)
));
// And the converse: a commit-domain signature must not verify
// under the tag domain.
let commit_sig = kp.sign(COMMIT_DOMAIN, bytes);
assert!(matches!(
verify(&kp.public, TAG_DOMAIN, bytes, &commit_sig),
Err(MkitError::SignatureInvalid)
));
}
// ------------------------------------------------------------------
// Determinism — Ed25519 deterministic signatures (RFC 8032).
// ------------------------------------------------------------------
#[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);
}
// ------------------------------------------------------------------
// Key file I/O.
// ------------------------------------------------------------------
#[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);
}
/// If the key file already exists with a wider mode (e.g. from an
/// older mkit that wrote 0o644), saving again MUST tighten it to
/// 0o600. The hardened path writes a fresh temp file created at mode
/// 0o600 (via `O_CREAT|O_EXCL`) and `rename(2)`s it over the target,
/// so the wide-mode inode is replaced wholesale — the resulting file
/// carries the temp file's tight mode regardless of the old mode, and
/// there is no `open()`/`set_permissions()` window for an attacker to
/// swap in a different inode.
#[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");
// Pre-seed a wide-mode file at the target path.
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();
// Loosen perms to 0644 — load must reject.
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;
// File mode 0600 + parent dir 0700 — load_key gates on
// both (parent dir mode was added with the
// hardening pass).
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 })
));
}
/// `load_key` opens with `O_NOFOLLOW` and refuses any path
/// whose final component is a symlink. Defends against an attacker
/// who can pre-create the path as a symlink and redirect us to a
/// 32-byte file they control.
#[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();
// Create a symlink at `link.key` → `real.key`. Both files end
// up under the same 0o700 parent dir.
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:?}"),
}
}
/// `load_key` refuses when the immediate parent directory
/// is group/world-accessible — `inotify`-watch + symlink-swap
/// attacks are out of scope, but we don't make them easy.
#[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();
// save_key just tightened the dir to 0o700; loosen it again
// to simulate a host where `.mkit/keys/` was created via a
// non-mkit tool that ignored the mode.
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:?}"),
}
}
/// `save_key` writes via a tmp file in the same directory
/// and renames atomically. Verifies a pre-existing key at the
/// target path is replaced cleanly (matches the old "tighten
/// pre-existing wide mode" regression in spirit).
#[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();
// Atomic rename gives us a fresh inode — unchanged inode would
// mean we had truncated-in-place, the bug we removed.
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:?}"),
}
}
// ------------------------------------------------------------------
// Zeroization regression guards
// ------------------------------------------------------------------
/// Direct invariant on `SecretSeed::zeroize()`: calling it must
/// scrub the inner bytes in place. This is the contract the
/// `ZeroizeOnDrop` impl relies on; if a future refactor swapped
/// `SecretSeed` for a type that didn't actually zero, this would
/// catch it before the drop-time test could.
#[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]);
}
/// Round-trip the new `from_seed_zeroizing` constructor: it must
/// produce the same `(public, secret)` pair as `from_seed`. The
/// public-key check is the easy half; the secret-bytes check is
/// the load-bearing one — it pins that no derivation step silently
/// rotates the stored seed.
#[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);
// And a sign / verify roundtrip with `b` to make sure the
// constructor doesn't silently break the signing pipeline.
let sig = b.sign(COMMIT_DOMAIN, b"x");
verify(&b.public, COMMIT_DOMAIN, b"x", &sig).expect("verify");
}
/// Structural pin on the REAL type's drop-time scrub wiring:
/// `SecretSeed` (the only owner of `KeyPair`'s secret bytes) must
/// keep implementing `ZeroizeOnDrop`. If a refactor removed the
/// derive (turning drop into a plain memory release), this stops
/// compiling — the value-level half (a `zeroize()` pass actually
/// clearing the bytes) is pinned by
/// `secret_seed_zeroize_clears_bytes` above. Together they cover
/// what the old sentinel-struct test only pretended to: the real
/// `SecretSeed`, not a test-local stand-in.
#[test]
fn keypair_drop_runs_zeroize_on_secret() {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<SecretSeed>();
// And the field wiring: the secret a `KeyPair` holds IS a
// `SecretSeed` (not a raw array that would drop without a
// scrub), carrying the caller's seed bytes.
let kp = KeyPair::from_seed([0xDEu8; 32]);
let seed_ref: &SecretSeed = &kp.secret;
assert_eq!(seed_ref.0, [0xDEu8; 32]);
drop(kp);
}
// Tiny self-contained tempdir helper — we don't want to pull in the
// `tempfile` crate just for two tests. Each call returns a fresh
// dir under `std::env::temp_dir()` named with a per-process counter
// and a high-resolution timestamp.
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
}
}