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}