Skip to main content

mkit_core/
sign.rs

1//! Ed25519 commit / remix signing.
2//!
3//! Spec: `docs/specs/SPEC-SIGNING.md`. The exact bytes covered by an Ed25519
4//! signature, and the domain separator used, are normative; this module
5//! reproduces them byte-for-byte. The golden tests in
6//! `tests/golden_sign.rs` pin the output.
7//!
8//! Briefly:
9//!
10//! * Algorithm: Ed25519 per RFC 8032, signing the **BLAKE3 digest** of
11//! `domain || signing_bytes` (`PureEdDSA` over a pre-hashed message —
12//! the digest itself is what is signed; we do *not* use Ed25519ph).
13//! * Domain separator is byte-prepended to the signing bytes; the
14//! trailing `\x00` is part of the domain (see SPEC §2).
15//! * `commit_signing_bytes` and `remix_signing_bytes` deliberately
16//! exclude `signature`, `message_hash`, and `content_digest` (commit
17//! only) — see SPEC §3.
18//!
19//! Keys live on disk as the raw 32-byte Ed25519 seed at
20//! `.mkit/keys/default.key`, mode 0600. Public-key derivation is
21//! deterministic from the seed.
22
23use crate::hash::{HASH_LEN, Hash};
24use crate::object::{
25    Commit, Identity, MAGIC, MkitError, Object, ObjectType, Remix, SCHEMA_VERSION, Tag,
26};
27
28use core::fmt;
29use std::path::Path;
30
31use ed25519_dalek::{
32    PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH, Signature as DalekSignature, Signer,
33    SigningKey, VerifyingKey,
34};
35use subtle::ConstantTimeEq;
36use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
37
38/// Effective uid for Unix key-file owner checks.
39#[cfg(unix)]
40#[must_use]
41pub fn effective_uid() -> u32 {
42    // SAFETY: `geteuid(2)` is a parameterless syscall that always succeeds,
43    // never reads or writes user memory, and is reentrant.
44    #[allow(unsafe_code)]
45    unsafe {
46        libc::geteuid()
47    }
48}
49
50/// Domain separator used when signing commit objects. The trailing
51/// `\x00` is load-bearing — see `docs/specs/SPEC-SIGNING.md` §2. Twelve bytes.
52pub const COMMIT_DOMAIN: &[u8] = b"mkit.commit\x00";
53
54/// Domain separator used when signing remix objects. Eleven bytes
55/// including the trailing `\x00`.
56pub const REMIX_DOMAIN: &[u8] = b"mkit.remix\x00";
57
58/// Domain separator used when signing annotated/signed tag objects
59/// (issue #230). Nine bytes including the trailing `\x00`.
60///
61/// DELIBERATELY DISTINCT from [`COMMIT_DOMAIN`] / [`REMIX_DOMAIN`] so a
62/// tag signature can never be replayed as a commit/remix signature, or
63/// vice versa — see `docs/specs/SPEC-SIGNING.md` §2 and §4a.
64pub const TAG_DOMAIN: &[u8] = b"mkit.tag\x00";
65
66/// 32-byte Ed25519 public key.
67#[derive(Clone, Copy, PartialEq, Eq, Hash)]
68pub struct PublicKey(pub [u8; PUBLIC_KEY_LENGTH]);
69
70impl fmt::Debug for PublicKey {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.debug_tuple("PublicKey").field(&"…").finish()
73    }
74}
75
76/// 32-byte Ed25519 *seed* (the private value we persist to disk).
77///
78/// This is **not** the expanded RFC 8032 secret key; it is the raw
79/// 32-byte input to `SHA512(seed)` from which the scalar and prefix are
80/// derived. We deliberately mirror what `.mkit/keys/default.key` stores.
81///
82/// The wrapped bytes are zeroed on drop.
83#[derive(Clone, Zeroize, ZeroizeOnDrop)]
84pub struct SecretSeed(pub [u8; SECRET_KEY_LENGTH]);
85
86impl fmt::Debug for SecretSeed {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        f.debug_tuple("SecretSeed").field(&"<redacted>").finish()
89    }
90}
91
92impl PartialEq for SecretSeed {
93    /// Constant-time equality via [`subtle::ConstantTimeEq`]. The
94    /// previous hand-rolled XOR-OR loop was correct in practice but
95    /// LLVM is permitted to short-circuit such loops, so we delegate
96    /// to a primitive whose contract pins constant-time semantics.
97    fn eq(&self, other: &Self) -> bool {
98        bool::from(self.0.ct_eq(&other.0))
99    }
100}
101impl Eq for SecretSeed {}
102
103/// 64-byte Ed25519 signature (R || s).
104#[derive(Clone, Copy, PartialEq, Eq, Hash)]
105pub struct Signature(pub [u8; SIGNATURE_LENGTH]);
106
107impl fmt::Debug for Signature {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_tuple("Signature").field(&"…").finish()
110    }
111}
112
113/// Ed25519 keypair: seed plus the deterministically-derived public key.
114#[derive(Debug, PartialEq, Eq)]
115pub struct KeyPair {
116    pub public: PublicKey,
117    pub secret: SecretSeed,
118}
119
120impl KeyPair {
121    /// Generate a fresh keypair using the system CSPRNG (`getrandom`).
122    ///
123    /// # Zeroization
124    ///
125    /// The local seed lives inside a [`Zeroizing`] wrapper that scrubs
126    /// the buffer at end of scope, so the only remaining copy is the
127    /// one inside the returned `KeyPair` (zeroized on drop via
128    /// `SecretSeed`'s [`ZeroizeOnDrop`]).
129    pub fn generate() -> Result<Self, MkitError> {
130        let mut seed: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
131        getrandom::fill(seed.as_mut_slice()).map_err(|_| MkitError::RngFailure)?;
132        Ok(Self::from_seed_zeroizing(&seed))
133    }
134
135    /// Reconstruct a keypair deterministically from a 32-byte seed.
136    /// Pure function: same seed always yields the same public key.
137    ///
138    /// This is a **self-scrubbing convenience constructor**: it zeroes
139    /// the `seed` argument it owns before returning (see the body), so
140    /// the moved-in buffer never lingers. It is kept as a public,
141    /// ergonomic entry point for callers that already hold a bare
142    /// `[u8; 32]` (e.g. test vectors, golden fixtures, and downstream /
143    /// WASM consumers that decode a seed from their own format).
144    ///
145    /// # Zeroization
146    ///
147    /// The contract this constructor guarantees: the `[u8; 32]` *passed
148    /// by value into this function* is scrubbed before return. What it
149    /// CANNOT do is reach back and scrub a `Copy` the caller left on
150    /// *their own* stack — `[u8; 32]: Copy`, so the argument is a moved
151    /// copy of whatever the caller held. Callers that keep sensitive
152    /// seed material on their own frame MUST therefore either:
153    ///
154    /// * Prefer [`KeyPair::from_seed_zeroizing`], which takes a
155    ///   [`Zeroizing`]-wrapped reference and never creates a Copy on
156    ///   the caller's frame (this is what ALL internal mkit signing-path
157    ///   code uses — `generate`, `load_key`, the attest signer factory,
158    ///   and the WASM bindings), or
159    /// * Wrap their seed in [`Zeroizing`] themselves, or
160    /// * `seed.zeroize()` the buffer after this call returns.
161    ///
162    /// [`KeyPair::generate`] and [`load_key`] already use the
163    /// `Zeroizing` path internally; no production call site passes a
164    /// bare `[u8; 32]` here. The contract above is pinned by the
165    /// `from_seed_zeroizing_matches_from_seed`,
166    /// `secret_seed_zeroize_clears_bytes` and
167    /// `keypair_drop_runs_zeroize_on_secret` regression tests. (The
168    /// owned-argument scrub two lines below is unobservable from
169    /// outside the function and is therefore covered by review, not by
170    /// a test — a previous test only ever scrubbed its own local
171    /// copy.)
172    #[must_use]
173    pub fn from_seed(mut seed: [u8; SECRET_KEY_LENGTH]) -> Self {
174        let signing = SigningKey::from_bytes(&seed);
175        let public = PublicKey(signing.verifying_key().to_bytes());
176        // `[u8; 32]: Copy`, so moving `seed` into `SecretSeed` would
177        // leave the original stack slot live. Build the wrapper first,
178        // then scrub the parameter — `SecretSeed`'s `ZeroizeOnDrop`
179        // owns the only remaining copy.
180        let secret = SecretSeed(seed);
181        seed.zeroize();
182        Self { public, secret }
183    }
184
185    /// Reconstruct a keypair from a [`Zeroizing`]-wrapped 32-byte seed
186    /// without forcing the caller to keep a `Copy` of the raw bytes on
187    /// their own stack. This is the preferred constructor for
188    /// signing-path code that loads keys from disk (see [`load_key`])
189    /// or generates them on the fly (see [`KeyPair::generate`]).
190    ///
191    /// # Zeroization
192    ///
193    /// Borrowing the seed means this function never creates a fresh
194    /// `[u8; 32]` `Copy` on the caller's frame. The only memory copy
195    /// is the one owned by the returned `KeyPair::secret` field, which
196    /// zeroes on drop.
197    #[must_use]
198    pub fn from_seed_zeroizing(seed: &Zeroizing<[u8; SECRET_KEY_LENGTH]>) -> Self {
199        let signing = SigningKey::from_bytes(seed);
200        let public = PublicKey(signing.verifying_key().to_bytes());
201        // `**seed` would be a `Copy` of the inner array — to avoid
202        // that we initialise the destination zeroed and `copy_from_slice`
203        // through the borrow, so the inner array never materialises a
204        // second `Copy` on this frame.
205        let mut secret_bytes = [0u8; SECRET_KEY_LENGTH];
206        secret_bytes.copy_from_slice(seed.as_slice());
207        let secret = SecretSeed(secret_bytes);
208        // Scrub our local stack scratch even though we just moved the
209        // bytes into `SecretSeed` — the stack slot would otherwise
210        // retain the seed until the frame is reused.
211        secret_bytes.zeroize();
212        Self { public, secret }
213    }
214
215    /// Sign `signing_bytes` under the given domain. The actual Ed25519
216    /// input is `BLAKE3(len_le16(domain) || domain || signing_bytes)` — see
217    /// SPEC §2.2.
218    #[must_use]
219    pub fn sign(&self, domain: &[u8], signing_bytes: &[u8]) -> Signature {
220        let digest = domain_digest(domain, signing_bytes);
221        let signing = SigningKey::from_bytes(&self.secret.0);
222        let sig = signing.sign(&digest);
223        Signature(sig.to_bytes())
224    }
225}
226
227/// Verify a signature over `BLAKE3(len_le16(domain) || domain || signing_bytes)`
228/// against the embedded public key. Returns `Ok(())` on success.
229///
230/// Uses [`VerifyingKey::verify_strict`], which enforces ZIP-215 / RFC 8032
231/// strict-verification semantics:
232///
233/// - The signature's `R` component is checked to be a canonical (i.e.
234/// least-representation) encoding of a curve point — torsion-component
235/// malleability is rejected.
236/// - The signature's `s` component is checked to be canonical mod the
237/// group order — high-s malleability is rejected.
238/// - The public key's `A` component is checked to be canonical — small-
239/// subgroup attacks are rejected.
240///
241/// The looser default `VerifyingKey::verify` accepts non-canonical
242/// encodings for backwards compat with older Ed25519 implementations;
243/// mkit has no such compat constraint (golden vectors are regenerated
244/// from our own signer) so we hold the stricter line.
245pub fn verify(
246    public: &PublicKey,
247    domain: &[u8],
248    signing_bytes: &[u8],
249    sig: &Signature,
250) -> Result<(), MkitError> {
251    let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
252    let dalek_sig = DalekSignature::from_bytes(&sig.0);
253    let digest = domain_digest(domain, signing_bytes);
254    vk.verify_strict(&digest, &dalek_sig)
255        .map_err(|_| MkitError::SignatureInvalid)
256}
257
258/// Batch-verify many Ed25519 signatures over pre-computed digests in one
259/// pass. Each entry is `(public_key, digest, sig)`, where `digest` is
260/// exactly what [`verify`] computes internally — `BLAKE3(len_le16(domain)
261/// || domain || signing_bytes)` — so callers typically supply
262/// [`commit_signing_hash`]/[`remix_signing_hash`]/[`tag_signing_hash`]'s
263/// output directly. Returns `Ok(())` only if every signature is valid.
264///
265/// Uses [`ed25519_dalek::verify_batch`]'s randomized-coefficient
266/// multiscalar-multiplication equation: verifying `n` signatures this way
267/// costs roughly one multiscalar multiplication over `2n + 1` points
268/// instead of `n` independent double-scalar multiplications, which is
269/// strictly less total work than calling [`verify`] `n` times — the
270/// speedup [`ed25519_dalek`]'s own benchmarks put at 20-30%+ over
271/// individually-verified batches of 4 and up. The trade-off: on failure
272/// this only proves *at least one* entry is invalid, never which one —
273/// callers that need to attribute the failure to a specific entry (mkit
274/// does, to report which fetched object was unsigned/forged) must fall
275/// back to calling [`verify`] (or [`verify_commit`]/[`verify_remix`]/
276/// [`verify_tag`]) per entry on `Err`.
277///
278/// `verify_batch` itself performs neither of `verify_strict`'s two extra
279/// malleability checks on its own — by the batch equation's own design
280/// (see `ed25519-dalek`'s README §"Malleability"), folding per-entry
281/// checks into the batch multiscalar multiplication would defeat the
282/// point of a high-throughput check, so the crate deliberately leaves
283/// them out and documents `VerifyingKey::is_weak` as the caller's
284/// responsibility (its README covers only the public-key half; the
285/// signature-`R` half is the same class of check, just undocumented as
286/// a batch caveat). This function performs both itself, per entry,
287/// before running the batch equation:
288///
289/// - `VerifyingKey::is_weak` — a weak (small-order) *public key* can
290///   produce a signature valid for nearly any message, so a batch
291///   containing one could accept a forgery `verify_strict` would reject.
292/// - a small-order *signature `R`* — checked directly here via
293///   `curve25519-dalek` (`CompressedEdwardsY::decompress` +
294///   `EdwardsPoint::is_small_order`, the same check `verify_strict`
295///   performs internally), matching `verify_strict`'s stated defense
296///   against "torsion-component malleability" for `R` (see [`verify`]'s
297///   doc comment) rather than leaving that half unchecked.
298///
299/// Confirmed by direct experiment against `ed25519-dalek` (not just
300/// from its docs) that this second check is real, not redundant: taking
301/// a genuine signature from a normal (non-weak) key and replacing its
302/// `R` with `R + T` for a nonzero 8-torsion point `T` is rejected by
303/// `ed25519-dalek`'s own loose `verify` too — its cofactorless
304/// recompute-and-compare of `R` can't be fooled by adding a torsion
305/// component this way, so that specific construction isn't a live
306/// forgery path against `verify_batch` either. What the added check
307/// closes is the one small-order value *within* the base subgroup —
308/// `R = identity` — which uniquely can satisfy the equation without
309/// tripping the "doesn't decompress to the base subgroup" failure the
310/// experiment hit (at the cost of the signer's own private key to
311/// compute a matching `s`; the resulting signature is an alternate
312/// valid *encoding* for a message that key already authorized, not an
313/// unauthorized forgery). Kept regardless: it costs one cheap
314/// decompress-and-check per entry, and it makes this function's accept
315/// set a true subset of `verify`'s rather than a documented
316/// approximation of one — the kind of gap this codebase does not leave
317/// open once it's identified (see the CHANGELOG's own history of this
318/// exact "close the residual gap even when the win is small" call).
319#[cfg(feature = "batch-verify")]
320pub fn verify_batch(entries: &[(PublicKey, Hash, Signature)]) -> Result<(), MkitError> {
321    if entries.is_empty() {
322        return Ok(());
323    }
324    let mut verifying_keys = Vec::with_capacity(entries.len());
325    for (public, _, sig) in entries {
326        let vk = VerifyingKey::from_bytes(&public.0).map_err(|_| MkitError::InvalidPublicKey)?;
327        if vk.is_weak() {
328            return Err(MkitError::SignatureInvalid);
329        }
330        let dalek_sig = DalekSignature::from_bytes(&sig.0);
331        let r_is_small_order = curve25519_dalek::edwards::CompressedEdwardsY(*dalek_sig.r_bytes())
332            .decompress()
333            .is_none_or(|r| r.is_small_order());
334        if r_is_small_order {
335            return Err(MkitError::SignatureInvalid);
336        }
337        verifying_keys.push(vk);
338    }
339    let messages: Vec<&[u8]> = entries
340        .iter()
341        .map(|(_, digest, _)| digest.as_slice())
342        .collect();
343    let signatures: Vec<DalekSignature> = entries
344        .iter()
345        .map(|(_, _, sig)| DalekSignature::from_bytes(&sig.0))
346        .collect();
347    ed25519_dalek::verify_batch(&messages, &signatures, &verifying_keys)
348        .map_err(|_| MkitError::SignatureInvalid)
349}
350
351// -------------------------------------------------------------------
352// Signing-bytes builders
353// -------------------------------------------------------------------
354
355/// Compute `BLAKE3(len_le16(domain) || domain || signing_bytes)`.
356/// Always 32 bytes.
357///
358/// Thin wrapper around the public [`crate::hash::domain_digest`].
359/// Kept as a module-private alias because the original v0.1.0
360/// signature scheme is golden-vector-pinned through this symbol; the
361/// public hoist (Reuse B2) added the same routine to `mkit_core::hash`
362/// for re-use by `sparse` etc., but the sign-path call sites
363/// deliberately retain this local indirection so a future refactor of
364/// the public function can't silently change signature output.
365///
366/// The 2-byte little-endian length prefix closes a latent ambiguity
367/// that `BLAKE3(domain || signing_bytes)` alone would carry: without
368/// a length prefix, the concatenation `domain || signing_bytes` is
369/// not uniquely parseable back into its two halves. Two distinct
370/// `(domain, signing_bytes)` pairs could in principle produce the
371/// same input to the hash (e.g. `("ab", "cX")` vs `("abc", "X")`).
372///
373/// Domain strings are fixed constants (`COMMIT_DOMAIN` /
374/// `REMIX_DOMAIN`) and always fit in `u16`; construction-time asserts
375/// this in `sign()` / `verify()` call sites.
376///
377/// NOTE: This is a wire/signature change vs the original v0.1.0
378/// format. Any signatures produced before this change do NOT verify
379/// under the new digest. A coordinated CHANGELOG entry documents the
380/// break; there are no shipped artefacts to migrate.
381#[must_use]
382fn domain_digest(domain: &[u8], signing_bytes: &[u8]) -> [u8; HASH_LEN] {
383    crate::hash::domain_digest(domain, signing_bytes)
384}
385
386/// Public helper:
387/// `BLAKE3(len_le16(COMMIT_DOMAIN) || COMMIT_DOMAIN || commit_signing_bytes(c))`.
388pub fn commit_signing_hash(c: &Commit) -> Result<Hash, MkitError> {
389    let sb = commit_signing_bytes(c)?;
390    Ok(domain_digest(COMMIT_DOMAIN, &sb))
391}
392
393/// Public helper:
394/// `BLAKE3(len_le16(REMIX_DOMAIN) || REMIX_DOMAIN || remix_signing_bytes(r))`.
395pub fn remix_signing_hash(r: &Remix) -> Result<Hash, MkitError> {
396    let sb = remix_signing_bytes(r)?;
397    Ok(domain_digest(REMIX_DOMAIN, &sb))
398}
399
400/// Public helper:
401/// `BLAKE3(len_le16(TAG_DOMAIN) || TAG_DOMAIN || tag_signing_bytes(t))`.
402pub fn tag_signing_hash(t: &Tag) -> Result<Hash, MkitError> {
403    let sb = tag_signing_bytes(t)?;
404    Ok(domain_digest(TAG_DOMAIN, &sb))
405}
406
407fn write_prologue(buf: &mut Vec<u8>, t: ObjectType) {
408    buf.push(t as u8);
409    buf.extend_from_slice(&MAGIC);
410    buf.push(SCHEMA_VERSION);
411}
412
413fn write_identity(buf: &mut Vec<u8>, id: &Identity) -> Result<(), MkitError> {
414    if !id.is_valid() {
415        return Err(MkitError::InvalidIdentity);
416    }
417    buf.push(id.kind as u8);
418    let len = u16::try_from(id.bytes.len()).map_err(|_| MkitError::IdentityTooLarge)?;
419    buf.extend_from_slice(&len.to_le_bytes());
420    buf.extend_from_slice(&id.bytes);
421    Ok(())
422}
423
424/// Serialize a commit's fields for signing. SPEC-SIGNING §3.
425///
426/// INCLUDED, in order:
427/// 1. Object prologue: `[type=0x03][magic="MKT1"][schema_version=0x01]`.
428/// 2. `tree_hash` (32).
429/// 3. `parent_count` (u32 LE) and `parent_hash` × `parent_count` (32 each).
430/// 4. Identity author: `[kind:u8][len:u16 LE][payload:len]`.
431/// 5. `message_len` (u32 LE) and message bytes.
432/// 6. `timestamp` (u64 LE).
433/// 7. `signer` (32).
434///
435/// EXCLUDED: `signature`, `message_hash`, `content_digest`.
436pub fn commit_signing_bytes(c: &Commit) -> Result<Vec<u8>, MkitError> {
437    let mut buf = Vec::with_capacity(
438        6 + 32 + 4 + c.parents.len() * 32 + 3 + c.author.bytes.len() + 4 + c.message.len() + 8 + 32,
439    );
440    write_prologue(&mut buf, ObjectType::Commit);
441    buf.extend_from_slice(&c.tree_hash);
442    let parent_count = u32::try_from(c.parents.len()).map_err(|_| MkitError::TooManyParents)?;
443    buf.extend_from_slice(&parent_count.to_le_bytes());
444    for p in &c.parents {
445        buf.extend_from_slice(p);
446    }
447    write_identity(&mut buf, &c.author)?;
448    let mlen = u32::try_from(c.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
449    buf.extend_from_slice(&mlen.to_le_bytes());
450    buf.extend_from_slice(&c.message);
451    buf.extend_from_slice(&c.timestamp.to_le_bytes());
452    buf.extend_from_slice(&c.signer);
453    Ok(buf)
454}
455
456/// Serialize a remix's fields for signing. SPEC-SIGNING §4. Same shape
457/// as commit, with `source_count || sources` between parents and author.
458pub fn remix_signing_bytes(r: &Remix) -> Result<Vec<u8>, MkitError> {
459    let mut buf = Vec::with_capacity(
460        6 + 32
461            + 4
462            + r.parents.len() * 32
463            + 4
464            + r.sources.len() * 64
465            + 3
466            + r.author.bytes.len()
467            + 4
468            + r.message.len()
469            + 8
470            + 32,
471    );
472    write_prologue(&mut buf, ObjectType::Remix);
473    buf.extend_from_slice(&r.tree_hash);
474    let parent_count = u32::try_from(r.parents.len()).map_err(|_| MkitError::TooManyParents)?;
475    buf.extend_from_slice(&parent_count.to_le_bytes());
476    for p in &r.parents {
477        buf.extend_from_slice(p);
478    }
479    let source_count = u32::try_from(r.sources.len()).map_err(|_| MkitError::TooManySources)?;
480    buf.extend_from_slice(&source_count.to_le_bytes());
481    for s in &r.sources {
482        buf.extend_from_slice(&s.upstream_id);
483        buf.extend_from_slice(&s.commit_hash);
484    }
485    write_identity(&mut buf, &r.author)?;
486    let mlen = u32::try_from(r.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
487    buf.extend_from_slice(&mlen.to_le_bytes());
488    buf.extend_from_slice(&r.message);
489    buf.extend_from_slice(&r.timestamp.to_le_bytes());
490    buf.extend_from_slice(&r.signer);
491    Ok(buf)
492}
493
494/// Serialize a tag's fields for signing. SPEC-SIGNING §4a.
495///
496/// INCLUDED, in order:
497/// 1. Object prologue: `[type=0x07][magic="MKT1"][schema_version=0x01]`.
498/// 2. `target` (32) and `target_type` (u8).
499/// 3. `name`: `[len:u32 LE][name bytes]`.
500/// 4. Identity tagger: `[kind:u8][len:u16 LE][payload:len]`.
501/// 5. `message`: `[len:u32 LE][message bytes]`.
502/// 6. `timestamp` (u64 LE).
503/// 7. `signer` (32).
504///
505/// EXCLUDED: `signature` (a signature cannot cover itself).
506pub fn tag_signing_bytes(t: &Tag) -> Result<Vec<u8>, MkitError> {
507    if !t.name_is_valid() {
508        return Err(MkitError::TagNameInvalid);
509    }
510    if matches!(t.target_type, ObjectType::Delta) {
511        return Err(MkitError::TagTargetTypeInvalid(t.target_type as u8));
512    }
513    let mut buf = Vec::with_capacity(
514        6 + 32 + 1 + 4 + t.name.len() + 3 + t.tagger.bytes.len() + 4 + t.message.len() + 8 + 32,
515    );
516    write_prologue(&mut buf, ObjectType::Tag);
517    buf.extend_from_slice(&t.target);
518    buf.push(t.target_type as u8);
519    let nlen = u32::try_from(t.name.len()).map_err(|_| MkitError::TagNameInvalid)?;
520    buf.extend_from_slice(&nlen.to_le_bytes());
521    buf.extend_from_slice(&t.name);
522    write_identity(&mut buf, &t.tagger)?;
523    let mlen = u32::try_from(t.message.len()).map_err(|_| MkitError::UnexpectedEof)?;
524    buf.extend_from_slice(&mlen.to_le_bytes());
525    buf.extend_from_slice(&t.message);
526    buf.extend_from_slice(&t.timestamp.to_le_bytes());
527    buf.extend_from_slice(&t.signer);
528    Ok(buf)
529}
530
531/// Sign a tag object.
532pub fn sign_tag(t: &Tag, kp: &KeyPair) -> Result<Signature, MkitError> {
533    let sb = tag_signing_bytes(t)?;
534    Ok(kp.sign(TAG_DOMAIN, &sb))
535}
536
537/// Verify a tag against the public key embedded in `t.signer`.
538pub fn verify_tag(t: &Tag) -> Result<(), MkitError> {
539    let sb = tag_signing_bytes(t)?;
540    let pk = PublicKey(t.signer);
541    let sig = Signature(t.signature);
542    verify(&pk, TAG_DOMAIN, &sb, &sig)
543}
544
545/// Sign a commit object. Returns the 64-byte signature.
546pub fn sign_commit(c: &Commit, kp: &KeyPair) -> Result<Signature, MkitError> {
547    let sb = commit_signing_bytes(c)?;
548    Ok(kp.sign(COMMIT_DOMAIN, &sb))
549}
550
551/// Sign a remix object.
552pub fn sign_remix(r: &Remix, kp: &KeyPair) -> Result<Signature, MkitError> {
553    let sb = remix_signing_bytes(r)?;
554    Ok(kp.sign(REMIX_DOMAIN, &sb))
555}
556
557/// Verify a commit against the public key embedded in `c.signer`.
558///
559/// Returns `Ok(())` on success. Note: this does *not* check whether
560/// `c.author`'s payload matches `c.signer` — that is an application
561/// policy decision (see SPEC §6).
562pub fn verify_commit(c: &Commit) -> Result<(), MkitError> {
563    let sb = commit_signing_bytes(c)?;
564    let pk = PublicKey(c.signer);
565    let sig = Signature(c.signature);
566    verify(&pk, COMMIT_DOMAIN, &sb, &sig)
567}
568
569/// Verify a remix against the public key embedded in `r.signer`.
570pub fn verify_remix(r: &Remix) -> Result<(), MkitError> {
571    let sb = remix_signing_bytes(r)?;
572    let pk = PublicKey(r.signer);
573    let sig = Signature(r.signature);
574    verify(&pk, REMIX_DOMAIN, &sb, &sig)
575}
576
577/// Verify the signature a signed object carries: [`verify_commit`],
578/// [`verify_remix`] or [`verify_tag`] by type. `Blob`, `Tree`,
579/// `ChunkedBlob` and `Delta` carry no signature and return `Ok(())`.
580///
581/// The one per-object signature dispatch shared by push verification
582/// (`mkit_core::verify::verify_push`) and the CLI's fetch-side check.
583///
584/// # Errors
585///
586/// The underlying `verify_*` error for a commit, remix or tag whose
587/// signature does not verify under its embedded `signer`.
588pub fn verify_object_signature(obj: &Object) -> Result<(), MkitError> {
589    match obj {
590        Object::Commit(c) => verify_commit(c),
591        Object::Remix(r) => verify_remix(r),
592        Object::Tag(t) => verify_tag(t),
593        Object::Blob(_) | Object::Tree(_) | Object::ChunkedBlob(_) | Object::Delta(_) => Ok(()),
594    }
595}
596
597// -------------------------------------------------------------------
598// Key file I/O — `.mkit/keys/default.key`
599// -------------------------------------------------------------------
600//
601// The on-disk contract (SPEC-SIGNING §7):
602// - Path: caller-provided, conventionally `<repo>/.mkit/keys/default.key`.
603// - Contents: raw 32-byte Ed25519 seed (NOT the expanded secret key).
604// - Permissions: file 0600, parent directory 0700.
605// - Owner: must equal the calling process's effective uid.
606// - Symlink-resistant: open(2) uses `O_NOFOLLOW`; both the file and
607// any path component above it are refused if they're symlinks.
608// - Crash-atomic write: tmp + fsync + rename + dir-fsync.
609
610/// Load a keypair from `path`. Enforces the full disk contract above.
611///
612/// On non-Unix hosts the symlink/owner/mode checks are a no-op;
613/// callers should keep keys under `%USERPROFILE%` and rely on default
614/// ACLs (documented in `docs/specs/SPEC-SIGNING.md` §7).
615pub fn load_key(path: &Path) -> Result<KeyPair, MkitError> {
616    let seed = load_raw_32(path)?;
617    // Borrowing through `from_seed_zeroizing` avoids the `*seed` Copy
618    // the older path would synthesise on this frame.
619    Ok(KeyPair::from_seed_zeroizing(&seed))
620}
621
622/// Load a raw 32-byte secret from `path` with the same Unix hardening
623/// `load_key` uses for the Ed25519 seed path.
624///
625/// On Unix this rejects symlinks both at the final component and in any
626/// existing ancestor above it.
627pub fn load_raw_32(path: &Path) -> Result<zeroize::Zeroizing<[u8; 32]>, MkitError> {
628    #[cfg(unix)]
629    {
630        use std::io::Read as _;
631        use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
632        ensure_no_symlink_ancestors(path)?;
633        let mut f = std::fs::OpenOptions::new()
634            .read(true)
635            .custom_flags(libc::O_NOFOLLOW)
636            .open(path)
637            .map_err(|e| {
638                if e.raw_os_error() == Some(libc::ELOOP) {
639                    MkitError::KeyPathIsSymlink(path.display().to_string())
640                } else {
641                    MkitError::KeyIo(format!("open: {e}"))
642                }
643            })?;
644
645        // fstat the open handle, NOT the path — closes the TOCTOU
646        // window in which an attacker could rename(2) a hostile inode
647        // into place between a path-based `metadata()` and `read()`.
648        let meta = f
649            .metadata()
650            .map_err(|e| MkitError::KeyIo(format!("fstat: {e}")))?;
651
652        let mode = meta.mode() & 0o777;
653        if mode & 0o077 != 0 {
654            return Err(MkitError::InsecureKeyPermissions { actual: mode });
655        }
656
657        // SAFETY: `geteuid(2)` is a parameterless syscall that always
658        // succeeds, never reads or writes user memory, and is reentrant
659        // and async-signal-safe per POSIX. This is one of two reviewed
660        // `unsafe` blocks in `mkit-core` (the other being the
661        // `F_BARRIERFSYNC` fcntl in `batch.rs`); the crate keeps
662        // `deny(unsafe_code)` so each opt-out is reviewable.
663        let euid = effective_uid();
664        if meta.uid() != euid {
665            return Err(MkitError::InsecureKeyOwner {
666                actual: meta.uid(),
667                euid,
668            });
669        }
670
671        // Refuse to load when the parent directory itself is loose.
672        // We only check the immediate parent — auditing every
673        // ancestor is policy that belongs in the install/setup flow,
674        // not in every signing call.
675        if let Some(parent) = path.parent()
676            && !parent.as_os_str().is_empty()
677        {
678            check_parent_dir_secure(parent)?;
679        }
680
681        let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
682        if let Err(e) = f.read_exact(seed.as_mut_slice()) {
683            return if e.kind() == std::io::ErrorKind::UnexpectedEof {
684                Err(MkitError::InvalidKeyLength {
685                    actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
686                })
687            } else {
688                Err(MkitError::KeyIo(format!("read: {e}")))
689            };
690        }
691        // Reject longer files: we read 32 bytes, so anything left over
692        // is junk that almost certainly means the file isn't a valid
693        // mkit seed.
694        let mut probe = [0u8; 1];
695        let trailing = f
696            .read(&mut probe)
697            .map_err(|e| MkitError::KeyIo(format!("read trailing byte: {e}")))?;
698        if trailing != 0 {
699            return Err(MkitError::InvalidKeyLength {
700                actual: usize::try_from(meta.len()).unwrap_or(usize::MAX),
701            });
702        }
703        Ok(seed)
704    }
705    #[cfg(not(unix))]
706    {
707        let raw = std::fs::read(path).map_err(|e| MkitError::KeyIo(format!("read: {e}")))?;
708        if raw.len() != SECRET_KEY_LENGTH {
709            return Err(MkitError::InvalidKeyLength { actual: raw.len() });
710        }
711        let mut seed = zeroize::Zeroizing::new([0u8; SECRET_KEY_LENGTH]);
712        seed.copy_from_slice(&raw);
713        let mut raw = raw;
714        raw.zeroize();
715        Ok(seed)
716    }
717}
718
719#[cfg(unix)]
720fn check_parent_dir_secure(parent: &Path) -> Result<(), MkitError> {
721    use std::os::unix::fs::MetadataExt;
722    // If the parent doesn't exist, defer to the file open above which
723    // will already have failed; not our error to report.
724    let Ok(meta) = std::fs::metadata(parent) else {
725        return Ok(());
726    };
727    let mode = meta.mode() & 0o777;
728    if mode & 0o077 != 0 {
729        return Err(MkitError::InsecureKeyDir { actual: mode });
730    }
731    Ok(())
732}
733
734#[cfg(unix)]
735fn ensure_no_symlink_ancestors(path: &Path) -> Result<(), MkitError> {
736    let mut current = path.parent();
737    for _ in 0..3 {
738        let Some(dir) = current else {
739            break;
740        };
741        if dir.as_os_str().is_empty() {
742            break;
743        }
744        match std::fs::symlink_metadata(dir) {
745            Ok(meta) if meta.file_type().is_symlink() => {
746                return Err(MkitError::KeyPathIsSymlink(dir.display().to_string()));
747            }
748            Ok(_) => {}
749            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
750            Err(e) => return Err(MkitError::KeyIo(format!("lstat {}: {e}", dir.display()))),
751        }
752        current = dir.parent();
753    }
754    if let Ok(meta) = std::fs::symlink_metadata(path)
755        && meta.file_type().is_symlink()
756    {
757        return Err(MkitError::KeyPathIsSymlink(path.display().to_string()));
758    }
759    Ok(())
760}
761
762#[cfg(unix)]
763fn create_secure_dir_all(parent: &Path) -> Result<(), MkitError> {
764    use std::os::unix::fs::PermissionsExt;
765
766    ensure_no_symlink_ancestors(parent)?;
767    std::fs::create_dir_all(parent)
768        .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
769    std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
770        .map_err(|e| MkitError::KeyIo(format!("chmod parent: {e}")))?;
771    Ok(())
772}
773
774/// Persist a keypair to `path` as the raw 32-byte seed.
775///
776/// **Atomicity contract.** The write is crash-safe:
777///
778/// 1. Ensure the parent directory exists at mode 0700.
779/// 2. Write the seed to a uniquely-named tmp file in the same
780/// directory using `O_CREAT | O_EXCL | O_NOFOLLOW` and mode 0600.
781/// `O_EXCL` defeats a pre-created symlink at the tmp name; same
782/// directory ensures `rename(2)` is atomic on the same filesystem.
783/// 3. `fsync` the tmp file's data to disk.
784/// 4. `rename(2)` the tmp file to the final path. Replaces an
785/// existing key atomically; never leaves a half-written final.
786/// 5. `fsync` the parent directory so the rename itself is durable.
787///
788/// On Windows the file is written with default ACLs; users should keep
789/// `.mkit/keys/` inside `%USERPROFILE%` (SPEC-SIGNING §7).
790pub fn save_key(path: &Path, kp: &KeyPair) -> Result<(), MkitError> {
791    save_raw_32(path, &kp.secret.0)
792}
793
794/// Persist a raw 32-byte secret to `path` crash-atomically.
795pub fn save_raw_32(path: &Path, secret: &[u8; 32]) -> Result<(), MkitError> {
796    let parent: &Path = match path.parent() {
797        Some(p) if !p.as_os_str().is_empty() => p,
798        _ => Path::new("."),
799    };
800
801    #[cfg(unix)]
802    {
803        use std::io::Write as _;
804        use std::os::unix::fs::OpenOptionsExt;
805        create_secure_dir_all(parent)?;
806
807        let filename = path
808            .file_name()
809            .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
810        // `.<name>.tmp.<pid>` is unique enough across concurrent
811        // `keygen` runs and within the same dir guarantees rename is
812        // atomic.
813        let tmp_name = {
814            let mut s = std::ffi::OsString::from(".");
815            s.push(filename);
816            s.push(format!(".tmp.{}", std::process::id()));
817            s
818        };
819        let tmp_path = parent.join(&tmp_name);
820
821        // Open with O_CREAT|O_EXCL|O_NOFOLLOW + mode 0600.
822        let mut f = std::fs::OpenOptions::new()
823            .write(true)
824            .create_new(true)
825            .custom_flags(libc::O_NOFOLLOW)
826            .mode(0o600)
827            .open(&tmp_path)
828            .map_err(|e| MkitError::KeyIo(format!("open tmp {}: {e}", tmp_path.display())))?;
829        if let Err(e) = f.write_all(secret) {
830            let _ = std::fs::remove_file(&tmp_path);
831            return Err(MkitError::KeyIo(format!("write: {e}")));
832        }
833        if let Err(e) = f.sync_all() {
834            let _ = std::fs::remove_file(&tmp_path);
835            return Err(MkitError::KeyIo(format!("fsync tmp: {e}")));
836        }
837        // Drop the file handle before rename; some filesystems are
838        // happier this way.
839        drop(f);
840
841        if let Err(e) = std::fs::rename(&tmp_path, path) {
842            let _ = std::fs::remove_file(&tmp_path);
843            return Err(MkitError::KeyIo(format!("rename: {e}")));
844        }
845
846        // fsync the directory so the rename is durable across power
847        // loss. Failing this isn't a security issue (the previous
848        // committed state still verifies); it's a durability one.
849        let dir = std::fs::File::open(parent)
850            .map_err(|e| MkitError::KeyIo(format!("open dir for fsync: {e}")))?;
851        dir.sync_all()
852            .map_err(|e| MkitError::KeyIo(format!("fsync dir: {e}")))?;
853    }
854    #[cfg(not(unix))]
855    {
856        std::fs::create_dir_all(parent)
857            .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
858        // Windows path: write to a tmp file in the same directory, then
859        // rename. No POSIX permission knobs; the user-profile ACL is
860        // the only protection.
861        let filename = path
862            .file_name()
863            .ok_or_else(|| MkitError::KeyIo(format!("path has no filename: {}", path.display())))?;
864        let mut tmp_name = std::ffi::OsString::from(".");
865        tmp_name.push(filename);
866        tmp_name.push(format!(".tmp.{}", std::process::id()));
867        let tmp_path = parent.join(&tmp_name);
868        std::fs::write(&tmp_path, secret)
869            .map_err(|e| MkitError::KeyIo(format!("write tmp: {e}")))?;
870        if let Err(e) = std::fs::rename(&tmp_path, path) {
871            let _ = std::fs::remove_file(&tmp_path);
872            return Err(MkitError::KeyIo(format!("rename: {e}")));
873        }
874    }
875    Ok(())
876}
877
878/// Persist a raw 32-byte secret only if `path` does not already exist.
879///
880/// Returns `Ok(true)` when the key was created and `Ok(false)` when the
881/// destination already existed. The successful write path is crash-atomic and
882/// preserves the same parent-directory hardening as [`save_raw_32`].
883pub fn save_raw_32_create_new(path: &Path, secret: &[u8; 32]) -> Result<bool, MkitError> {
884    let parent: &Path = match path.parent() {
885        Some(p) if !p.as_os_str().is_empty() => p,
886        _ => Path::new("."),
887    };
888
889    #[cfg(unix)]
890    create_secure_dir_all(parent)?;
891    #[cfg(not(unix))]
892    std::fs::create_dir_all(parent)
893        .map_err(|e| MkitError::KeyIo(format!("mkdir {}: {e}", parent.display())))?;
894
895    crate::atomic::write_create_new(path, secret, false)
896        .map_err(|e| MkitError::KeyIo(format!("create key: {e}")))
897}
898
899// -------------------------------------------------------------------
900// Tests
901// -------------------------------------------------------------------
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::hash::{ZERO, hash};
907    use crate::object::{Identity, IdentityKind, ObjectType, RemixSource, Tag};
908
909    fn fixed_kp() -> KeyPair {
910        KeyPair::from_seed([0x42; 32])
911    }
912
913    fn ed25519_id(pk: [u8; 32]) -> Identity {
914        Identity {
915            kind: IdentityKind::Ed25519,
916            bytes: pk.to_vec(),
917        }
918    }
919
920    // ------------------------------------------------------------------
921    // Sign/verify roundtrip and tamper detection
922    // ------------------------------------------------------------------
923
924    #[test]
925    fn sign_verify_roundtrip() {
926        let kp = fixed_kp();
927        let bytes = b"some signing bytes";
928        let sig = kp.sign(COMMIT_DOMAIN, bytes);
929        verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("verify ok");
930    }
931
932    #[test]
933    fn verify_rejects_tampered_input() {
934        let kp = fixed_kp();
935        let bytes = b"original".to_vec();
936        let sig = kp.sign(COMMIT_DOMAIN, &bytes);
937        let mut tampered = bytes.clone();
938        tampered[0] ^= 0x01;
939        assert!(matches!(
940            verify(&kp.public, COMMIT_DOMAIN, &tampered, &sig),
941            Err(MkitError::SignatureInvalid)
942        ));
943    }
944
945    #[test]
946    fn verify_object_signature_dispatches_by_type() {
947        use crate::object::{Blob, Commit, Identity, Object};
948        let kp = fixed_kp();
949        let mut commit = Commit {
950            tree_hash: [1u8; 32],
951            parents: vec![],
952            author: Identity::ed25519(kp.public.0),
953            signer: kp.public.0,
954            message: b"m".to_vec(),
955            timestamp: 1,
956            message_hash: [0u8; 32],
957            content_digest: [0u8; 32],
958            signature: [0u8; 64],
959        };
960        commit.signature = sign_commit(&commit, &kp).expect("sign").0;
961        verify_object_signature(&Object::Commit(commit.clone())).expect("signed commit");
962        commit.message = b"tampered".to_vec();
963        assert!(matches!(
964            verify_object_signature(&Object::Commit(commit)),
965            Err(MkitError::SignatureInvalid)
966        ));
967        verify_object_signature(&Object::Blob(Blob { data: vec![] })).expect("unsigned kind");
968    }
969
970    #[test]
971    fn verify_rejects_wrong_key() {
972        let kp1 = fixed_kp();
973        let kp2 = KeyPair::from_seed([0x55; 32]);
974        let bytes = b"x";
975        let sig = kp1.sign(COMMIT_DOMAIN, bytes);
976        assert!(matches!(
977            verify(&kp2.public, COMMIT_DOMAIN, bytes, &sig),
978            Err(MkitError::SignatureInvalid)
979        ));
980    }
981
982    /// Regression guard on strict-verification compliance.
983    ///
984    /// Our `verify()` uses [`ed25519_dalek::VerifyingKey::verify_strict`],
985    /// which rejects the Ed25519 malleability vectors documented at
986    /// <https://hdevalence.ca/blog/2020-10-04-its-25519am>. This test
987    /// asserts that signatures produced by our own signer pass the
988    /// strict check — if `ed25519-dalek` ever starts producing
989    /// non-canonical `R` or high-`s` signatures, this fails first.
990    #[test]
991    fn our_signatures_pass_strict_verify() {
992        let kp = fixed_kp();
993        // Sample the signature space across several different inputs;
994        // a subtle drift in `s` normalization (e.g. if the underlying
995        // crate changed its reduction strategy) would surface on at
996        // least one of these.
997        for (i, input) in [
998            b"" as &[u8],
999            b"a",
1000            b"00000000000000000000000000000000",
1001            &[0xff; 64],
1002            &(0u8..=255).collect::<Vec<u8>>(),
1003        ]
1004        .iter()
1005        .enumerate()
1006        {
1007            let sig = kp.sign(COMMIT_DOMAIN, input);
1008            verify(&kp.public, COMMIT_DOMAIN, input, &sig)
1009                .unwrap_or_else(|e| panic!("input #{i} failed strict verify: {e:?}"));
1010        }
1011    }
1012
1013    /// A crafted, known non-canonical signature: take a signature our
1014    /// own signer produced and replace its `s` component with `s + L`
1015    /// (`L` = the Ed25519 base-point order). `s + L` is congruent to
1016    /// the original `s` modulo `L` — the same scalar the verification
1017    /// equation cares about — but is not the canonical
1018    /// least-representative encoding RFC 8032 / `verify_strict`
1019    /// requires. This is the classic Ed25519 high-`s` malleability
1020    /// vector (<https://hdevalence.ca/blog/2020-10-04-its-25519am>),
1021    /// constructed deterministically rather than by mutating a random
1022    /// byte of an otherwise-valid signature.
1023    #[test]
1024    fn verify_rejects_non_canonical_high_s_signature() {
1025        // L = 2^252 + 27742317777372353535851937790883648493, little-endian.
1026        const L: [u8; 32] = [
1027            0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9,
1028            0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1029            0x00, 0x00, 0x00, 0x10,
1030        ];
1031
1032        let kp = fixed_kp();
1033        let bytes = b"malleability test payload";
1034        let sig = kp.sign(COMMIT_DOMAIN, bytes);
1035        verify(&kp.public, COMMIT_DOMAIN, bytes, &sig).expect("original signature must verify");
1036
1037        // s_malleated = s + L, computed as a 256-bit little-endian
1038        // addition (s < L < 2^253, so s + L < 2^254 — no overflow past
1039        // 32 bytes).
1040        let mut malleated = sig.0;
1041        let mut carry: u16 = 0;
1042        for i in 0..32 {
1043            let sum = u16::from(malleated[32 + i]) + u16::from(L[i]) + carry;
1044            malleated[32 + i] = (sum & 0xFF) as u8;
1045            carry = sum >> 8;
1046        }
1047        assert_eq!(carry, 0, "s + L must not overflow 32 bytes");
1048        assert_ne!(
1049            malleated[32..],
1050            sig.0[32..],
1051            "the malleated signature must differ from the original"
1052        );
1053
1054        let bad_sig = Signature(malleated);
1055        assert!(matches!(
1056            verify(&kp.public, COMMIT_DOMAIN, bytes, &bad_sig),
1057            Err(MkitError::SignatureInvalid)
1058        ));
1059    }
1060
1061    // ------------------------------------------------------------------
1062    // Batch verification (`verify_batch`)
1063    // ------------------------------------------------------------------
1064
1065    #[cfg(feature = "batch-verify")]
1066    #[test]
1067    fn verify_batch_accepts_many_valid_signatures_under_distinct_keys() {
1068        let entries: Vec<(PublicKey, Hash, Signature)> = (0..40u8)
1069            .map(|i| {
1070                let kp = KeyPair::from_seed([i; 32]);
1071                let msg = format!("batch entry #{i}");
1072                let digest = domain_digest(COMMIT_DOMAIN, msg.as_bytes());
1073                let sig = kp.sign(COMMIT_DOMAIN, msg.as_bytes());
1074                (kp.public, digest, sig)
1075            })
1076            .collect();
1077        verify_batch(&entries).expect("every entry is validly signed");
1078    }
1079
1080    #[cfg(feature = "batch-verify")]
1081    #[test]
1082    fn verify_batch_empty_is_ok() {
1083        verify_batch(&[]).expect("an empty batch trivially verifies");
1084    }
1085
1086    #[cfg(feature = "batch-verify")]
1087    #[test]
1088    fn verify_batch_rejects_one_tampered_signature_among_many_valid() {
1089        let mut entries: Vec<(PublicKey, Hash, Signature)> = (0..16u8)
1090            .map(|i| {
1091                let kp = KeyPair::from_seed([i; 32]);
1092                let msg = format!("batch entry #{i}");
1093                let digest = domain_digest(COMMIT_DOMAIN, msg.as_bytes());
1094                let sig = kp.sign(COMMIT_DOMAIN, msg.as_bytes());
1095                (kp.public, digest, sig)
1096            })
1097            .collect();
1098        // Every entry individually verifies (against its digest,
1099        // matching what `verify` checks internally) before tampering.
1100        for (pk, digest, sig) in &entries {
1101            let vk = VerifyingKey::from_bytes(&pk.0).unwrap();
1102            let dalek_sig = DalekSignature::from_bytes(&sig.0);
1103            vk.verify_strict(digest, &dalek_sig)
1104                .expect("fixture entry must verify before tampering");
1105        }
1106        entries[8].2.0[0] ^= 0xff;
1107        let err = verify_batch(&entries).expect_err("a tampered entry must fail the batch");
1108        assert!(matches!(err, MkitError::SignatureInvalid));
1109    }
1110
1111    /// A genuine weak-key forgery, not just a mismatched signature: with
1112    /// `A` (the public key) equal to the curve's neutral element, the
1113    /// verification equation `[s]B == R + [k]A'` degenerates to `[s]B ==
1114    /// R` for every `k` (since `[k]A' = [k]·0 = 0` regardless of `k`), so
1115    /// `R = identity, s = 0` satisfies it for *any* message — no secret
1116    /// key involved. `VerifyingKey::verify` (loose) accepts this for
1117    /// arbitrary messages; only the weak-key check `verify_strict` (and
1118    /// this module's `verify_batch`) adds is what rejects it. Confirmed
1119    /// against `ed25519_dalek::VerifyingKey` directly (loose `verify`
1120    /// accepts, `verify_strict` rejects) before relying on it here, so
1121    /// this test is exercising `verify_batch`'s own weak-key guard
1122    /// specifically — not incidentally failing for an unrelated reason
1123    /// the way pairing the identity key with an arbitrary *valid*
1124    /// signature (from a different key) would.
1125    #[cfg(feature = "batch-verify")]
1126    #[test]
1127    fn verify_batch_rejects_a_weak_public_key_forgery() {
1128        let mut pk_bytes = [0u8; 32];
1129        pk_bytes[0] = 1; // compressed identity point (the neutral element)
1130        let identity_pk = PublicKey(pk_bytes);
1131        assert!(
1132            VerifyingKey::from_bytes(&identity_pk.0)
1133                .expect("identity point decodes")
1134                .is_weak(),
1135            "the compressed identity point must be flagged weak"
1136        );
1137
1138        let mut sig_bytes = [0u8; 64];
1139        sig_bytes[0] = 1; // R = identity; s = 0 (remaining bytes already zero)
1140        let forged_sig = Signature(sig_bytes);
1141        let digest = domain_digest(COMMIT_DOMAIN, b"any message at all, no key needed");
1142
1143        let entries = vec![(identity_pk, digest, forged_sig)];
1144        let err = verify_batch(&entries).expect_err("a weak-key forgery must be rejected");
1145        assert!(matches!(err, MkitError::SignatureInvalid));
1146    }
1147
1148    /// Isolates the small-order-`R` check from the weak-public-key check
1149    /// above: a genuinely *normal* (non-weak) key's real secret scalar
1150    /// is used to solve `[s]B = R + [k]A` for `R = identity` — the one
1151    /// small-order point that also lies in the base subgroup, so it's
1152    /// the only one this equation can be solved for at all (every other
1153    /// small-order `R` can't, per `verify_batch`'s doc comment; that's
1154    /// also why this construction needs the real secret key, unlike an
1155    /// outside forger who doesn't have it — see the same doc comment).
1156    ///
1157    /// Confirms the construction is load-bearing, not just malformed
1158    /// input: loose `verify` (no `R`/weak-key check) accepts it — so the
1159    /// batch equation, which performs the identical check, would too —
1160    /// while `verify_strict` and this module's `verify_batch` both
1161    /// reject it via their small-order checks.
1162    #[cfg(feature = "batch-verify")]
1163    #[test]
1164    fn verify_batch_rejects_a_small_order_r_forgery_from_a_normal_key() {
1165        use curve25519_dalek::scalar::Scalar;
1166        use ed25519_dalek::Verifier as _;
1167        use ed25519_dalek::hazmat::ExpandedSecretKey;
1168        use sha2::{Digest as _, Sha512};
1169
1170        let kp = fixed_kp();
1171        let vk = VerifyingKey::from_bytes(&kp.public.0).unwrap();
1172        assert!(!vk.is_weak(), "sanity: this key must not be weak");
1173
1174        // Expand the secret scalar exactly as real signing does (RFC
1175        // 8032 §5.1.5): a = clamp(SHA-512(seed))[0..32].
1176        let expanded_hash: [u8; 64] = Sha512::digest(kp.secret.0).into();
1177        let esk = ExpandedSecretKey::from_bytes(&expanded_hash);
1178
1179        let mut r_bytes = [0u8; 32];
1180        r_bytes[0] = 1; // R = compressed identity
1181
1182        let digest = domain_digest(COMMIT_DOMAIN, b"small-order-R forgery probe");
1183        // k = H(R || A || digest) mod L — the same hash-to-scalar step
1184        // `verify`/`verify_batch` both perform internally.
1185        let mut h = Sha512::new();
1186        h.update(r_bytes);
1187        h.update(kp.public.0);
1188        h.update(digest);
1189        let k_bytes: [u8; 64] = h.finalize().into();
1190        let k = Scalar::from_bytes_mod_order_wide(&k_bytes);
1191
1192        // s = k*a solves [s]B = [k]A = R + [k]A, since R = identity = [0]B.
1193        let s = k * esk.scalar;
1194        let mut sig_bytes = [0u8; 64];
1195        sig_bytes[..32].copy_from_slice(&r_bytes);
1196        sig_bytes[32..].copy_from_slice(s.as_bytes());
1197        let forged_sig = DalekSignature::from_bytes(&sig_bytes);
1198
1199        vk.verify(&digest, &forged_sig)
1200            .expect("forged signature must satisfy the loose verification equation");
1201        assert!(
1202            vk.verify_strict(&digest, &forged_sig).is_err(),
1203            "verify_strict must reject the small-order R"
1204        );
1205
1206        let entries = vec![(kp.public, digest, Signature(sig_bytes))];
1207        let err = verify_batch(&entries).expect_err("a small-order-R forgery must be rejected");
1208        assert!(matches!(err, MkitError::SignatureInvalid));
1209    }
1210
1211    // ------------------------------------------------------------------
1212    // Domain separation guard (SPEC §2.1)
1213    // ------------------------------------------------------------------
1214
1215    #[test]
1216    fn domain_separation_commit_vs_remix() {
1217        let kp = fixed_kp();
1218        let bytes = b"shared bytes";
1219        let sig = kp.sign(COMMIT_DOMAIN, bytes);
1220        // Same bytes, same key, but the wrong domain → MUST fail.
1221        assert!(matches!(
1222            verify(&kp.public, REMIX_DOMAIN, bytes, &sig),
1223            Err(MkitError::SignatureInvalid)
1224        ));
1225    }
1226
1227    #[test]
1228    fn domain_digest_differs_per_domain() {
1229        let bytes = b"abc";
1230        let a = domain_digest(COMMIT_DOMAIN, bytes);
1231        let b = domain_digest(REMIX_DOMAIN, bytes);
1232        assert_ne!(a, b);
1233    }
1234
1235    /// `domain_digest` hashes a 2-byte LE length prefix before the
1236    /// domain label, so
1237    /// `BLAKE3(len_le16(D) || D || M)` — not `BLAKE3(D || M)`. This
1238    /// closes a latent ambiguity between a domain `"ab"` + message
1239    /// `"cX"` vs domain `"abc"` + message `"X"` (same concatenation).
1240    ///
1241    /// Regression guard: compute the expected digest by hand and
1242    /// assert it matches. If anyone reverts the length prefix this
1243    /// trips before any golden-vector drift is noticed.
1244    #[test]
1245    fn domain_digest_includes_length_prefix() {
1246        let domain = b"ab";
1247        let msg = b"cX";
1248        let got = domain_digest(domain, msg);
1249        let mut want = blake3::Hasher::new();
1250        let len = u16::try_from(domain.len()).unwrap();
1251        want.update(&len.to_le_bytes());
1252        want.update(domain);
1253        want.update(msg);
1254        assert_eq!(got, *want.finalize().as_bytes());
1255
1256        // And the ambiguous case with different domain/message split
1257        // MUST produce a different digest.
1258        let other = domain_digest(b"abc", b"X");
1259        assert_ne!(got, other);
1260    }
1261
1262    // ------------------------------------------------------------------
1263    // RFC 8032 §7.1 known-answer test 1.
1264    //
1265    // The reference vector covers a *raw* empty message. PureEdDSA over
1266    // an empty input should produce the published signature. Since the
1267    // only thing our `sign()` API exposes is "domain || signing_bytes",
1268    // the simplest way to reproduce the vector is to call into the
1269    // dalek `SigningKey` directly. This guards against any accidental
1270    // change to the underlying primitive (e.g. someone swapping in
1271    // Ed25519ph would silently break interop).
1272    // ------------------------------------------------------------------
1273
1274    #[test]
1275    fn ed25519_rfc8032_vector_1() {
1276        // Test vector 1 from RFC 8032 §7.1.
1277        let seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
1278        let pk_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
1279        let sig_hex = concat!(
1280            "e5564300c360ac729086e2cc806e828a",
1281            "84877f1eb8e5d974d873e06522490155",
1282            "5fb8821590a33bacc61e39701cf9b46b",
1283            "d25bf5f0595bbe24655141438e7a100b",
1284        );
1285        let seed: [u8; 32] = hex::decode(seed_hex).unwrap().try_into().unwrap();
1286        let kp = KeyPair::from_seed(seed);
1287        // Round-trip the public key.
1288        assert_eq!(hex::encode(kp.public.0), pk_hex);
1289        // Sign the empty message — bypass our domain-prefix path so the
1290        // test reads as the RFC vector.
1291        let signing = SigningKey::from_bytes(&kp.secret.0);
1292        let sig = signing.sign(b"");
1293        assert_eq!(hex::encode(sig.to_bytes()), sig_hex);
1294    }
1295
1296    // ------------------------------------------------------------------
1297    // Commit / remix sign + verify (uses the full pipeline).
1298    // ------------------------------------------------------------------
1299
1300    fn build_commit(kp: &KeyPair, msg: &[u8]) -> Commit {
1301        Commit {
1302            tree_hash: hash(b"tree"),
1303            parents: vec![],
1304            author: ed25519_id(kp.public.0),
1305            signer: kp.public.0,
1306            message: msg.to_vec(),
1307            timestamp: 1_711_300_000,
1308            message_hash: ZERO,
1309            content_digest: ZERO,
1310            signature: [0u8; 64],
1311        }
1312    }
1313
1314    #[test]
1315    fn sign_then_verify_commit() {
1316        let kp = fixed_kp();
1317        let mut c = build_commit(&kp, b"hello");
1318        c.signature = sign_commit(&c, &kp).unwrap().0;
1319        verify_commit(&c).expect("verify ok");
1320    }
1321
1322    #[test]
1323    fn tampered_commit_message_fails_verify() {
1324        let kp = fixed_kp();
1325        let mut c = build_commit(&kp, b"hello");
1326        c.signature = sign_commit(&c, &kp).unwrap().0;
1327        c.message = b"tampered".to_vec();
1328        assert!(matches!(
1329            verify_commit(&c),
1330            Err(MkitError::SignatureInvalid)
1331        ));
1332    }
1333
1334    #[test]
1335    fn message_hash_does_not_affect_signing_bytes() {
1336        // Spec §3: `message_hash` and `content_digest` are EXCLUDED from
1337        // the signing bytes. Two commits differing only in those fields
1338        // must have identical signing bytes (and therefore identical
1339        // signatures under the same key).
1340        let kp = fixed_kp();
1341        let mut c1 = build_commit(&kp, b"x");
1342        let mut c2 = c1.clone();
1343        c2.message_hash = hash(b"some annotation");
1344        c2.content_digest = hash(b"another annotation");
1345        let sb1 = commit_signing_bytes(&c1).unwrap();
1346        let sb2 = commit_signing_bytes(&c2).unwrap();
1347        assert_eq!(sb1, sb2);
1348        c1.signature = sign_commit(&c1, &kp).unwrap().0;
1349        c2.signature = c1.signature;
1350        verify_commit(&c2).expect("annotation fields are not signed");
1351    }
1352
1353    #[test]
1354    fn sign_then_verify_remix() {
1355        let kp = fixed_kp();
1356        let mut r = Remix {
1357            tree_hash: hash(b"tree"),
1358            parents: vec![],
1359            sources: vec![RemixSource {
1360                upstream_id: hash(b"upstream"),
1361                commit_hash: hash(b"commit"),
1362            }],
1363            author: ed25519_id(kp.public.0),
1364            signer: kp.public.0,
1365            message: b"remix".to_vec(),
1366            timestamp: 2_000,
1367            signature: [0u8; 64],
1368        };
1369        r.signature = sign_remix(&r, &kp).unwrap().0;
1370        verify_remix(&r).expect("verify ok");
1371    }
1372
1373    // ------------------------------------------------------------------
1374    // Tag sign + verify + cross-protocol domain separation.
1375    // ------------------------------------------------------------------
1376
1377    fn build_tag(kp: &KeyPair, msg: &[u8]) -> Tag {
1378        Tag {
1379            target: hash(b"target"),
1380            target_type: ObjectType::Commit,
1381            name: b"v1.0.0".to_vec(),
1382            tagger: ed25519_id(kp.public.0),
1383            signer: kp.public.0,
1384            message: msg.to_vec(),
1385            timestamp: 1_711_300_000,
1386            signature: [0u8; 64],
1387        }
1388    }
1389
1390    #[test]
1391    fn sign_then_verify_tag() {
1392        let kp = fixed_kp();
1393        let mut t = build_tag(&kp, b"release");
1394        t.signature = sign_tag(&t, &kp).unwrap().0;
1395        verify_tag(&t).expect("verify ok");
1396    }
1397
1398    #[test]
1399    fn tampered_tag_message_fails_verify() {
1400        let kp = fixed_kp();
1401        let mut t = build_tag(&kp, b"release");
1402        t.signature = sign_tag(&t, &kp).unwrap().0;
1403        t.message = b"tampered".to_vec();
1404        assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1405    }
1406
1407    #[test]
1408    fn tampered_tag_target_fails_verify() {
1409        let kp = fixed_kp();
1410        let mut t = build_tag(&kp, b"release");
1411        t.signature = sign_tag(&t, &kp).unwrap().0;
1412        t.target = hash(b"other");
1413        assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1414    }
1415
1416    #[test]
1417    fn annotated_unsigned_tag_fails_verify() {
1418        // A tag carrying a full, otherwise-legitimate annotation body
1419        // (name, tagger, message, timestamp) but an all-zero signature
1420        // — the sentinel git-bridge import produces for an
1421        // annotated-but-unsigned git tag — must never verify. The
1422        // all-zero bytes are not a magic "no signature" marker; they
1423        // are just another (invalid) signature value.
1424        let kp = fixed_kp();
1425        let mut t = build_tag(&kp, b"release");
1426        t.signature = [0u8; 64];
1427        assert!(matches!(verify_tag(&t), Err(MkitError::SignatureInvalid)));
1428    }
1429
1430    #[test]
1431    fn tag_domain_differs_from_commit_and_remix() {
1432        // The three domains must be pairwise distinct constants.
1433        assert_ne!(TAG_DOMAIN, COMMIT_DOMAIN);
1434        assert_ne!(TAG_DOMAIN, REMIX_DOMAIN);
1435        let bytes = b"abc";
1436        let dt = domain_digest(TAG_DOMAIN, bytes);
1437        assert_ne!(dt, domain_digest(COMMIT_DOMAIN, bytes));
1438        assert_ne!(dt, domain_digest(REMIX_DOMAIN, bytes));
1439    }
1440
1441    /// Cross-protocol replay guard: a signature produced over the tag
1442    /// domain MUST NOT verify under the commit/remix domain (and vice
1443    /// versa), even with the same key and the same signing bytes.
1444    #[test]
1445    fn tag_signature_does_not_verify_as_commit_or_remix() {
1446        let kp = fixed_kp();
1447        let bytes = b"shared signing bytes";
1448        let tag_sig = kp.sign(TAG_DOMAIN, bytes);
1449        assert!(matches!(
1450            verify(&kp.public, COMMIT_DOMAIN, bytes, &tag_sig),
1451            Err(MkitError::SignatureInvalid)
1452        ));
1453        assert!(matches!(
1454            verify(&kp.public, REMIX_DOMAIN, bytes, &tag_sig),
1455            Err(MkitError::SignatureInvalid)
1456        ));
1457        // And the converse: a commit-domain signature must not verify
1458        // under the tag domain.
1459        let commit_sig = kp.sign(COMMIT_DOMAIN, bytes);
1460        assert!(matches!(
1461            verify(&kp.public, TAG_DOMAIN, bytes, &commit_sig),
1462            Err(MkitError::SignatureInvalid)
1463        ));
1464    }
1465
1466    // ------------------------------------------------------------------
1467    // Determinism — Ed25519 deterministic signatures (RFC 8032).
1468    // ------------------------------------------------------------------
1469
1470    #[test]
1471    fn signing_is_deterministic() {
1472        let kp = fixed_kp();
1473        let bytes = b"deterministic";
1474        let s1 = kp.sign(COMMIT_DOMAIN, bytes);
1475        let s2 = kp.sign(COMMIT_DOMAIN, bytes);
1476        assert_eq!(s1.0, s2.0);
1477    }
1478
1479    // ------------------------------------------------------------------
1480    // Key file I/O.
1481    // ------------------------------------------------------------------
1482
1483    #[test]
1484    fn save_then_load_roundtrip() {
1485        let dir = tempdir();
1486        let p = dir.join("default.key");
1487        let kp = KeyPair::from_seed([0x77; 32]);
1488        save_key(&p, &kp).unwrap();
1489        let kp2 = load_key(&p).unwrap();
1490        assert_eq!(kp.public.0, kp2.public.0);
1491        assert_eq!(kp.secret.0, kp2.secret.0);
1492    }
1493
1494    #[cfg(unix)]
1495    #[test]
1496    fn save_key_writes_mode_0600() {
1497        use std::os::unix::fs::MetadataExt;
1498        let dir = tempdir();
1499        let p = dir.join("default.key");
1500        let kp = KeyPair::from_seed([0x33; 32]);
1501        save_key(&p, &kp).unwrap();
1502        let meta = std::fs::metadata(&p).unwrap();
1503        assert_eq!(meta.mode() & 0o777, 0o600);
1504    }
1505
1506    /// If the key file already exists with a wider mode (e.g. from an
1507    /// older mkit that wrote 0o644), saving again MUST tighten it to
1508    /// 0o600. The hardened path writes a fresh temp file created at mode
1509    /// 0o600 (via `O_CREAT|O_EXCL`) and `rename(2)`s it over the target,
1510    /// so the wide-mode inode is replaced wholesale — the resulting file
1511    /// carries the temp file's tight mode regardless of the old mode, and
1512    /// there is no `open()`/`set_permissions()` window for an attacker to
1513    /// swap in a different inode.
1514    #[cfg(unix)]
1515    #[test]
1516    fn save_key_tightens_preexisting_wide_mode_to_0600() {
1517        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1518        let dir = tempdir();
1519        let p = dir.join("default.key");
1520        // Pre-seed a wide-mode file at the target path.
1521        std::fs::write(&p, b"old contents").unwrap();
1522        let mut perm = std::fs::metadata(&p).unwrap().permissions();
1523        perm.set_mode(0o644);
1524        std::fs::set_permissions(&p, perm).unwrap();
1525        assert_eq!(
1526            std::fs::metadata(&p).unwrap().mode() & 0o777,
1527            0o644,
1528            "sanity: pre-seeded 0o644"
1529        );
1530
1531        let kp = KeyPair::from_seed([0x55; 32]);
1532        save_key(&p, &kp).unwrap();
1533
1534        let meta = std::fs::metadata(&p).unwrap();
1535        assert_eq!(meta.mode() & 0o777, 0o600);
1536    }
1537
1538    #[cfg(unix)]
1539    #[test]
1540    fn load_key_rejects_world_readable() {
1541        use std::os::unix::fs::PermissionsExt;
1542        let dir = tempdir();
1543        let p = dir.join("default.key");
1544        let kp = KeyPair::from_seed([0x33; 32]);
1545        save_key(&p, &kp).unwrap();
1546        // Loosen perms to 0644 — load must reject.
1547        let mut perm = std::fs::metadata(&p).unwrap().permissions();
1548        perm.set_mode(0o644);
1549        std::fs::set_permissions(&p, perm).unwrap();
1550        match load_key(&p) {
1551            Err(MkitError::InsecureKeyPermissions { actual }) => {
1552                assert_eq!(actual, 0o644);
1553            }
1554            other => panic!("expected InsecureKeyPermissions, got {other:?}"),
1555        }
1556    }
1557
1558    #[test]
1559    fn load_key_rejects_wrong_length() {
1560        let dir = tempdir();
1561        let p = dir.join("short.key");
1562        std::fs::write(&p, b"too short").unwrap();
1563        #[cfg(unix)]
1564        {
1565            use std::os::unix::fs::PermissionsExt;
1566            // File mode 0600 + parent dir 0700 — load_key gates on
1567            // both (parent dir mode was added with the
1568            // hardening pass).
1569            let mut perm = std::fs::metadata(&p).unwrap().permissions();
1570            perm.set_mode(0o600);
1571            std::fs::set_permissions(&p, perm).unwrap();
1572            let mut dperm = std::fs::metadata(&dir).unwrap().permissions();
1573            dperm.set_mode(0o700);
1574            std::fs::set_permissions(&dir, dperm).unwrap();
1575        }
1576        assert!(matches!(
1577            load_key(&p),
1578            Err(MkitError::InvalidKeyLength { actual: 9 })
1579        ));
1580    }
1581
1582    /// `load_key` opens with `O_NOFOLLOW` and refuses any path
1583    /// whose final component is a symlink. Defends against an attacker
1584    /// who can pre-create the path as a symlink and redirect us to a
1585    /// 32-byte file they control.
1586    #[cfg(unix)]
1587    #[test]
1588    fn load_key_rejects_symlink() {
1589        use std::os::unix::fs::PermissionsExt;
1590        let dir = tempdir();
1591        let real = dir.join("real.key");
1592        let kp = KeyPair::from_seed([0xAB; 32]);
1593        save_key(&real, &kp).unwrap();
1594        // Create a symlink at `link.key` → `real.key`. Both files end
1595        // up under the same 0o700 parent dir.
1596        let link = dir.join("link.key");
1597        std::os::unix::fs::symlink(&real, &link).unwrap();
1598        let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1599        perm.set_mode(0o700);
1600        std::fs::set_permissions(&dir, perm).unwrap();
1601        match load_key(&link) {
1602            Err(MkitError::KeyPathIsSymlink(_)) => {}
1603            other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1604        }
1605    }
1606
1607    #[cfg(unix)]
1608    #[test]
1609    fn load_key_rejects_symlinked_ancestor() {
1610        use std::os::unix::fs::PermissionsExt;
1611        let dir = tempdir();
1612        let real_parent = dir.join("realkeys");
1613        std::fs::create_dir_all(&real_parent).unwrap();
1614        let mut parent_perm = std::fs::metadata(&real_parent).unwrap().permissions();
1615        parent_perm.set_mode(0o700);
1616        std::fs::set_permissions(&real_parent, parent_perm).unwrap();
1617
1618        let real = real_parent.join("default.key");
1619        let kp = KeyPair::from_seed([0xBC; 32]);
1620        save_key(&real, &kp).unwrap();
1621
1622        let symlink_parent = dir.join("symlink-keys");
1623        std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1624        match load_key(&symlink_parent.join("default.key")) {
1625            Err(MkitError::KeyPathIsSymlink(_)) => {}
1626            other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1627        }
1628    }
1629
1630    /// `load_key` refuses when the immediate parent directory
1631    /// is group/world-accessible — `inotify`-watch + symlink-swap
1632    /// attacks are out of scope, but we don't make them easy.
1633    #[cfg(unix)]
1634    #[test]
1635    fn load_key_rejects_world_readable_parent() {
1636        use std::os::unix::fs::PermissionsExt;
1637        let dir = tempdir();
1638        let p = dir.join("default.key");
1639        let kp = KeyPair::from_seed([0xCD; 32]);
1640        save_key(&p, &kp).unwrap();
1641        // save_key just tightened the dir to 0o700; loosen it again
1642        // to simulate a host where `.mkit/keys/` was created via a
1643        // non-mkit tool that ignored the mode.
1644        let mut perm = std::fs::metadata(&dir).unwrap().permissions();
1645        perm.set_mode(0o755);
1646        std::fs::set_permissions(&dir, perm).unwrap();
1647        match load_key(&p) {
1648            Err(MkitError::InsecureKeyDir { actual }) => {
1649                assert_eq!(actual, 0o755);
1650            }
1651            other => panic!("expected InsecureKeyDir, got {other:?}"),
1652        }
1653    }
1654
1655    /// `save_key` writes via a tmp file in the same directory
1656    /// and renames atomically. Verifies a pre-existing key at the
1657    /// target path is replaced cleanly (matches the old "tighten
1658    /// pre-existing wide mode" regression in spirit).
1659    #[cfg(unix)]
1660    #[test]
1661    fn save_key_replaces_existing_key_atomically() {
1662        use std::os::unix::fs::MetadataExt;
1663        let dir = tempdir();
1664        let p = dir.join("default.key");
1665        let kp1 = KeyPair::from_seed([0x11; 32]);
1666        save_key(&p, &kp1).unwrap();
1667        let inode_before = std::fs::metadata(&p).unwrap().ino();
1668
1669        let kp2 = KeyPair::from_seed([0x22; 32]);
1670        save_key(&p, &kp2).unwrap();
1671        let meta_after = std::fs::metadata(&p).unwrap();
1672        // Atomic rename gives us a fresh inode — unchanged inode would
1673        // mean we had truncated-in-place, the bug we removed.
1674        assert_ne!(
1675            meta_after.ino(),
1676            inode_before,
1677            "save_key must replace via rename, not truncate-in-place"
1678        );
1679        assert_eq!(meta_after.mode() & 0o777, 0o600);
1680        let kp_loaded = load_key(&p).unwrap();
1681        assert_eq!(kp_loaded.public.0, kp2.public.0);
1682    }
1683
1684    #[test]
1685    fn save_raw_32_create_new_refuses_existing_key() {
1686        let dir = tempdir();
1687        let p = dir.join("default.key");
1688        assert!(save_raw_32_create_new(&p, &[0x11; 32]).unwrap());
1689        assert!(!save_raw_32_create_new(&p, &[0x22; 32]).unwrap());
1690        assert_eq!(&*load_raw_32(&p).unwrap(), &[0x11; 32]);
1691    }
1692
1693    #[cfg(unix)]
1694    #[test]
1695    fn save_key_rejects_symlinked_ancestor() {
1696        let dir = tempdir();
1697        let real_parent = dir.join("realkeys");
1698        std::fs::create_dir_all(&real_parent).unwrap();
1699        let symlink_parent = dir.join("symlink-keys");
1700        std::os::unix::fs::symlink(&real_parent, &symlink_parent).unwrap();
1701        let kp = KeyPair::from_seed([0x44; 32]);
1702        match save_key(&symlink_parent.join("default.key"), &kp) {
1703            Err(MkitError::KeyPathIsSymlink(_)) => {}
1704            other => panic!("expected KeyPathIsSymlink, got {other:?}"),
1705        }
1706    }
1707
1708    // ------------------------------------------------------------------
1709    // Zeroization regression guards
1710    // ------------------------------------------------------------------
1711
1712    /// Direct invariant on `SecretSeed::zeroize()`: calling it must
1713    /// scrub the inner bytes in place. This is the contract the
1714    /// `ZeroizeOnDrop` impl relies on; if a future refactor swapped
1715    /// `SecretSeed` for a type that didn't actually zero, this would
1716    /// catch it before the drop-time test could.
1717    #[test]
1718    fn secret_seed_zeroize_clears_bytes() {
1719        let mut s = SecretSeed([0xAAu8; SECRET_KEY_LENGTH]);
1720        s.zeroize();
1721        assert_eq!(s.0, [0u8; SECRET_KEY_LENGTH]);
1722    }
1723
1724    /// Round-trip the new `from_seed_zeroizing` constructor: it must
1725    /// produce the same `(public, secret)` pair as `from_seed`. The
1726    /// public-key check is the easy half; the secret-bytes check is
1727    /// the load-bearing one — it pins that no derivation step silently
1728    /// rotates the stored seed.
1729    #[test]
1730    fn from_seed_zeroizing_matches_from_seed() {
1731        let raw = [0x9Au8; SECRET_KEY_LENGTH];
1732        let wrapped: Zeroizing<[u8; SECRET_KEY_LENGTH]> = Zeroizing::new(raw);
1733        let a = KeyPair::from_seed(raw);
1734        let b = KeyPair::from_seed_zeroizing(&wrapped);
1735        assert_eq!(a.public.0, b.public.0);
1736        assert_eq!(a.secret.0, b.secret.0);
1737        // And a sign / verify roundtrip with `b` to make sure the
1738        // constructor doesn't silently break the signing pipeline.
1739        let sig = b.sign(COMMIT_DOMAIN, b"x");
1740        verify(&b.public, COMMIT_DOMAIN, b"x", &sig).expect("verify");
1741    }
1742
1743    /// Structural pin on the REAL type's drop-time scrub wiring:
1744    /// `SecretSeed` (the only owner of `KeyPair`'s secret bytes) must
1745    /// keep implementing `ZeroizeOnDrop`. If a refactor removed the
1746    /// derive (turning drop into a plain memory release), this stops
1747    /// compiling — the value-level half (a `zeroize()` pass actually
1748    /// clearing the bytes) is pinned by
1749    /// `secret_seed_zeroize_clears_bytes` above. Together they cover
1750    /// what the old sentinel-struct test only pretended to: the real
1751    /// `SecretSeed`, not a test-local stand-in.
1752    #[test]
1753    fn keypair_drop_runs_zeroize_on_secret() {
1754        fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
1755        assert_zeroize_on_drop::<SecretSeed>();
1756
1757        // And the field wiring: the secret a `KeyPair` holds IS a
1758        // `SecretSeed` (not a raw array that would drop without a
1759        // scrub), carrying the caller's seed bytes.
1760        let kp = KeyPair::from_seed([0xDEu8; 32]);
1761        let seed_ref: &SecretSeed = &kp.secret;
1762        assert_eq!(seed_ref.0, [0xDEu8; 32]);
1763        drop(kp);
1764    }
1765
1766    // Tiny self-contained tempdir helper — we don't want to pull in the
1767    // `tempfile` crate just for two tests. Each call returns a fresh
1768    // dir under `std::env::temp_dir()` named with a per-process counter
1769    // and a high-resolution timestamp.
1770    fn tempdir() -> std::path::PathBuf {
1771        use std::sync::atomic::{AtomicU64, Ordering};
1772        use std::time::{SystemTime, UNIX_EPOCH};
1773        static COUNTER: AtomicU64 = AtomicU64::new(0);
1774        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1775        let nanos = SystemTime::now()
1776            .duration_since(UNIX_EPOCH)
1777            .map_or(0, |d| d.as_nanos());
1778        let p =
1779            std::env::temp_dir().join(format!("mkit-sign-test-{nanos}-{n}-{}", std::process::id()));
1780        std::fs::create_dir_all(&p).unwrap();
1781        p
1782    }
1783}