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