Skip to main content

cortex_core/
attestor.rs

1//! Actor attestation primitives — `Attestor` trait, `Attestation` value
2//! type, `verify`, and identity-rotation envelope (T-3.D.0, ADR 0010 +
3//! ADR 0014).
4//!
5//! This crate owns **only** the trait and the canonical-bytes verifier.
6//! OS-keychain backends (macOS Keychain / Linux Secret Service / Windows
7//! DPAPI) are scaffolded behind `#[cfg(target_os = "...")]` modules with
8//! `unimplemented!()` bodies that compile on every platform but panic at
9//! runtime; v0 of cortex uses [`InMemoryAttestor`] in tests and the CLI
10//! init flow (lane T-3.D.5/6 will wire up the real backends).
11//!
12//! ## Verify-side fail-closed contract (ADR 0010 §1b)
13//!
14//! [`verify`] **MUST** reject any preimage whose `schema_version` differs
15//! from [`crate::canonical::SCHEMA_VERSION_ATTESTATION`]. There is no
16//! "best effort" decode and no partial verification: an unknown version is
17//! a hard error.
18//!
19//! ## Future flag: `--require-presence`
20//!
21//! ADR 0010 §5 specifies a `--require-presence` flag that asks the OS for
22//! user-presence (Touch ID, etc.) before signing. That flag is plumbed in
23//! the CLI lane (T-3.D.5 / T-3.D.6) — this trait deliberately does **not**
24//! take a `require_presence: bool` parameter in v0 because the in-memory
25//! attestor cannot honor it. When the keychain backends land, add a method
26//! `sign_with_presence(&self, signing_input: &[u8]) -> Result<Signature, _>`
27//! on the trait (default-impl returning the same bytes) so existing
28//! callers keep compiling.
29//
30// TODO(T-3.D.5/6): plumb `--require-presence` through this trait once the
31// OS keychain backends are functional.
32
33use chrono::{DateTime, Utc};
34use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
35use serde::{Deserialize, Serialize};
36
37use crate::canonical::{
38    canonical_rotation_input, canonical_signing_input, AttestationPreimage,
39    SCHEMA_VERSION_ATTESTATION,
40};
41
42/// Errors raised by [`verify`] and the rotation-envelope verifier.
43///
44/// Variants are deliberately distinct so audit code can log *why* a
45/// signature failed (unknown schema vs key-id mismatch vs bad signature)
46/// without having to parse a free-form string.
47#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
48pub enum VerifyError {
49    /// Preimage carried a `schema_version` this build does not understand.
50    /// Per ADR 0010 §1b this is a hard, fail-closed error.
51    #[error("unknown attestation schema_version: found {found}, expected {expected}")]
52    UnknownSchemaVersion {
53        /// Version observed in the preimage.
54        found: u16,
55        /// Version this build understands.
56        expected: u16,
57    },
58    /// The `key_id` in the preimage did not match the verifying public
59    /// key's expected fingerprint. Defends against signature substitution
60    /// across keys.
61    #[error("key_id mismatch: preimage says {preimage}, verifier expected {expected}")]
62    KeyIdMismatch {
63        /// `key_id` declared in the preimage.
64        preimage: String,
65        /// `key_id` the verifier was configured with.
66        expected: String,
67    },
68    /// Ed25519 signature verification failed (wrong key, wrong bytes,
69    /// truncated signature, …). Catch-all for cryptographic failure.
70    #[error("ed25519 signature verification failed")]
71    BadSignature,
72    /// Signature bytes could not be parsed as Ed25519 (e.g. wrong length).
73    #[error("malformed signature bytes")]
74    MalformedSignature,
75}
76
77/// Generates Ed25519 attestations over a canonical signing input.
78///
79/// Implementors MUST be `Send + Sync` so a single attestor instance can be
80/// shared between request handlers and background workers without locking.
81///
82/// The trait is intentionally narrow: it does not own the canonical
83/// encoder. Callers build an [`AttestationPreimage`], pipe it through
84/// [`canonical_signing_input`], and pass the resulting bytes to [`Self::sign`].
85/// This separation lets hardware-token backends sign opaque bytes without
86/// having to understand the cortex schema.
87pub trait Attestor: Send + Sync {
88    /// Sign the canonical signing input bytes.
89    ///
90    /// Implementations MAY surface a presence prompt, hardware-token
91    /// confirmation, etc. **Errors are intentionally not modeled here in
92    /// v0** — the in-memory attestor cannot fail, and OS-keychain failures
93    /// are surfaced as panics until the CLI lanes wire in a richer
94    /// `Result` type. (TODO: T-3.D.5/6 — change return type to `Result<…>`.)
95    fn sign(&self, signing_input: &[u8]) -> Signature;
96
97    /// Stable fingerprint of the public verifying key. Embedded into every
98    /// attestation preimage and matched on verify (see
99    /// [`VerifyError::KeyIdMismatch`]).
100    fn key_id(&self) -> &str;
101
102    /// The verifying key for this attestor — exposed so test harnesses and
103    /// `cortex audit verify` can reconstruct the public side without going
104    /// through OS-specific lookup. OS-keychain backends MUST publish the
105    /// public key alongside the private key (see ADR 0010 §3).
106    fn verifying_key(&self) -> VerifyingKey;
107}
108
109/// Cryptographic proof that an event originated from the named principal.
110///
111/// Wire shape (ADR 0014 §"Signed preimage"). Note: the **`signature`**
112/// field is the only part of an event payload **not** included in the
113/// canonical preimage (a 64-byte signature cannot sign itself), but
114/// `key_id` and `signed_at` **are** included so a captured signature
115/// cannot be moved to a different timestamp or rebound to a different key.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Attestation {
118    /// Public-key fingerprint (matches [`Attestor::key_id`]).
119    pub key_id: String,
120    /// Ed25519 signature over [`canonical_signing_input`].
121    #[serde(with = "signature_serde")]
122    pub signature: [u8; 64],
123    /// Wall-clock timestamp the signature was produced at; MUST equal the
124    /// `signed_at` field in the preimage that was signed.
125    pub signed_at: DateTime<Utc>,
126}
127
128/// Identity-rotation envelope — proves the holder of `old_pubkey` (or the
129/// recovery key) authorizes `new_pubkey` to take over (ADR 0010 §6).
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct RotationEnvelope {
132    /// Schema version for the rotation envelope canonical bytes.
133    /// Bumped independently of [`SCHEMA_VERSION_ATTESTATION`] when the
134    /// rotation framing changes.
135    pub schema_version: u16,
136    /// Old public key bytes (32 bytes, raw — not multibase).
137    pub old_pubkey: [u8; 32],
138    /// New public key bytes (32 bytes, raw — not multibase).
139    pub new_pubkey: [u8; 32],
140    /// When the rotation was signed.
141    pub signed_at: DateTime<Utc>,
142    /// Ed25519 signature over [`canonical_rotation_input`] using the
143    /// **old** signing key (or recovery key, where applicable).
144    #[serde(with = "signature_serde")]
145    pub signature: [u8; 64],
146}
147
148// -- impl ---------------------------------------------------------------
149
150/// Sign an [`AttestationPreimage`] and produce a verifiable
151/// [`Attestation`].
152///
153/// Convenience wrapper around [`canonical_signing_input`] +
154/// [`Attestor::sign`]; takes the `signed_at` from the preimage so the two
155/// sides cannot drift.
156#[must_use]
157pub fn attest(preimage: &AttestationPreimage, attestor: &dyn Attestor) -> Attestation {
158    let bytes = canonical_signing_input(preimage);
159    let sig = attestor.sign(&bytes);
160    Attestation {
161        key_id: preimage.key_id.clone(),
162        signature: sig.to_bytes(),
163        signed_at: preimage.signed_at,
164    }
165}
166
167/// Verify an [`Attestation`] against the canonical preimage and a
168/// verifying public key.
169///
170/// Fail-closed contract:
171///
172/// - `preimage.schema_version != SCHEMA_VERSION_ATTESTATION` →
173///   [`VerifyError::UnknownSchemaVersion`].
174/// - `preimage.key_id != expected_key_id` →
175///   [`VerifyError::KeyIdMismatch`].
176/// - signature does not verify under `public_key` over the canonical
177///   bytes of `preimage` → [`VerifyError::BadSignature`].
178///
179/// **Note on `expected_key_id`**: callers pass the fingerprint they
180/// believe `public_key` represents. This binds the public key to its
181/// declared identity at the verify boundary; constructions that derive
182/// `key_id` from `public_key` directly (e.g. `multibase(public_key)`)
183/// can pass `&derived_key_id`.
184pub fn verify(
185    preimage: &AttestationPreimage,
186    attestation: &Attestation,
187    public_key: &VerifyingKey,
188    expected_key_id: &str,
189) -> Result<(), VerifyError> {
190    // Fail-closed schema check FIRST (ADR 0010 §1b).
191    if preimage.schema_version != SCHEMA_VERSION_ATTESTATION {
192        return Err(VerifyError::UnknownSchemaVersion {
193            found: preimage.schema_version,
194            expected: SCHEMA_VERSION_ATTESTATION,
195        });
196    }
197
198    // Match the preimage's declared key_id against the caller's expected
199    // key_id. This stops a captured signature from being verified under a
200    // different key whose owner happened to publish the same bytes.
201    if preimage.key_id != expected_key_id || attestation.key_id != expected_key_id {
202        return Err(VerifyError::KeyIdMismatch {
203            preimage: preimage.key_id.clone(),
204            expected: expected_key_id.to_string(),
205        });
206    }
207
208    // signed_at must round-trip: the preimage encoder takes signed_at from
209    // the preimage struct, so if they disagree the signed bytes will not
210    // match. Belt-and-braces: also reject mismatched signed_at up front.
211    if preimage.signed_at != attestation.signed_at {
212        return Err(VerifyError::BadSignature);
213    }
214
215    let signing_input = canonical_signing_input(preimage);
216    let sig = Signature::from_bytes(&attestation.signature);
217    public_key
218        .verify(&signing_input, &sig)
219        .map_err(|_| VerifyError::BadSignature)
220}
221
222/// Sign an identity-rotation envelope using the **old** key (or recovery
223/// key) — ADR 0010 §6.
224#[must_use]
225pub fn sign_rotation(
226    old_pubkey: &VerifyingKey,
227    new_pubkey: &VerifyingKey,
228    signed_at: DateTime<Utc>,
229    attestor: &dyn Attestor,
230) -> RotationEnvelope {
231    let old_bytes = old_pubkey.to_bytes();
232    let new_bytes = new_pubkey.to_bytes();
233    let bytes = canonical_rotation_input(
234        SCHEMA_VERSION_ATTESTATION,
235        &old_bytes,
236        &new_bytes,
237        signed_at,
238    );
239    let sig = attestor.sign(&bytes);
240    RotationEnvelope {
241        schema_version: SCHEMA_VERSION_ATTESTATION,
242        old_pubkey: old_bytes,
243        new_pubkey: new_bytes,
244        signed_at,
245        signature: sig.to_bytes(),
246    }
247}
248
249/// Verify an identity-rotation envelope against the **old** verifying key.
250/// Fails closed on unknown schema version.
251pub fn verify_rotation(env: &RotationEnvelope) -> Result<(), VerifyError> {
252    if env.schema_version != SCHEMA_VERSION_ATTESTATION {
253        return Err(VerifyError::UnknownSchemaVersion {
254            found: env.schema_version,
255            expected: SCHEMA_VERSION_ATTESTATION,
256        });
257    }
258    let old_pk =
259        VerifyingKey::from_bytes(&env.old_pubkey).map_err(|_| VerifyError::MalformedSignature)?;
260    let bytes = canonical_rotation_input(
261        env.schema_version,
262        &env.old_pubkey,
263        &env.new_pubkey,
264        env.signed_at,
265    );
266    let sig = Signature::from_bytes(&env.signature);
267    old_pk
268        .verify(&bytes, &sig)
269        .map_err(|_| VerifyError::BadSignature)
270}
271
272// -- in-memory attestor (test + bootstrap use) ---------------------------
273
274/// In-memory Ed25519 attestor. Used in tests, fixtures, and the bootstrap
275/// path before the OS keychain is wired up.
276///
277/// **Not** suitable for production: the signing key is held in plain
278/// process memory. Production paths use one of the OS-keychain backends
279/// scaffolded below.
280pub struct InMemoryAttestor {
281    signing_key: SigningKey,
282    key_id: String,
283}
284
285impl std::fmt::Debug for InMemoryAttestor {
286    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287        f.debug_struct("InMemoryAttestor")
288            .field("key_id", &self.key_id)
289            // Deliberately omit the signing key.
290            .finish_non_exhaustive()
291    }
292}
293
294impl InMemoryAttestor {
295    /// Wrap a pre-existing signing key with an explicit `key_id`.
296    pub fn from_signing_key(signing_key: SigningKey, key_id: impl Into<String>) -> Self {
297        Self {
298            signing_key,
299            key_id: key_id.into(),
300        }
301    }
302
303    /// Build from a 32-byte secret seed. Useful for fixtures and tests
304    /// where the keypair must be reproducible. `key_id` is derived as the
305    /// lowercase hex of the public key (32 bytes → 64 hex chars).
306    #[must_use]
307    pub fn from_seed(seed: &[u8; 32]) -> Self {
308        let signing_key = SigningKey::from_bytes(seed);
309        let pk = signing_key.verifying_key();
310        let key_id = hex_lower(&pk.to_bytes());
311        Self {
312            signing_key,
313            key_id,
314        }
315    }
316}
317
318impl Attestor for InMemoryAttestor {
319    fn sign(&self, signing_input: &[u8]) -> Signature {
320        self.signing_key.sign(signing_input)
321    }
322
323    fn key_id(&self) -> &str {
324        &self.key_id
325    }
326
327    fn verifying_key(&self) -> VerifyingKey {
328        self.signing_key.verifying_key()
329    }
330}
331
332/// Hex (lowercase, no separator) for fingerprint derivation. Local helper
333/// — `cortex-core` does not pull in `hex` to keep the dep surface tight.
334fn hex_lower(bytes: &[u8]) -> String {
335    let mut s = String::with_capacity(bytes.len() * 2);
336    for b in bytes {
337        s.push_str(&format!("{b:02x}"));
338    }
339    s
340}
341
342// -- OS keychain skeletons (cfg-gated, unimplemented in v0) --------------
343
344/// Marker trait for "this attestor records a future identity-rotation
345/// commitment in addition to signing payloads." Implementors emit a
346/// rotation envelope when a new key is provisioned. Implementations land
347/// alongside the keychain backends.
348pub trait IdentityRotation: Attestor {
349    /// Sign a rotation envelope `(old → new)` using the **old** key
350    /// material. See [`sign_rotation`] for the free-function form.
351    fn sign_rotation(&self, new_pubkey: &VerifyingKey, signed_at: DateTime<Utc>) -> RotationEnvelope
352    where
353        Self: Sized,
354    {
355        sign_rotation(&self.verifying_key(), new_pubkey, signed_at, self)
356    }
357}
358
359impl IdentityRotation for InMemoryAttestor {}
360
361/// macOS Keychain backend skeleton. **Unimplemented in v0** — see ADR
362/// 0010 §3 and the T-3.D.5 / T-3.D.6 follow-up lanes.
363#[cfg(target_os = "macos")]
364#[derive(Debug)]
365pub struct KeychainAttestor {
366    key_id: String,
367}
368
369#[cfg(target_os = "macos")]
370impl KeychainAttestor {
371    /// Look up the cortex-identity key by `key_id` in the macOS Keychain.
372    /// **Unimplemented in v0** — panics on construction. TODO(T-3.D.5/6).
373    pub fn open(_key_id: impl Into<String>) -> Self {
374        unimplemented!(
375            "KeychainAttestor (macOS) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
376        );
377    }
378
379    /// `key_id` getter (works without OS calls so the type can be
380    /// constructed in tests behind a fixture).
381    #[must_use]
382    pub fn key_id(&self) -> &str {
383        &self.key_id
384    }
385}
386
387#[cfg(target_os = "macos")]
388impl Attestor for KeychainAttestor {
389    fn sign(&self, _signing_input: &[u8]) -> Signature {
390        unimplemented!("KeychainAttestor::sign (macOS) — see T-3.D.5/6")
391    }
392    fn key_id(&self) -> &str {
393        &self.key_id
394    }
395    fn verifying_key(&self) -> VerifyingKey {
396        unimplemented!("KeychainAttestor::verifying_key (macOS) — see T-3.D.5/6")
397    }
398}
399
400/// Linux Secret Service backend skeleton. **Unimplemented in v0**.
401#[cfg(target_os = "linux")]
402#[derive(Debug)]
403pub struct KeychainAttestor {
404    key_id: String,
405}
406
407#[cfg(target_os = "linux")]
408impl KeychainAttestor {
409    /// Look up the cortex-identity key via D-Bus Secret Service.
410    /// **Unimplemented in v0**. TODO(T-3.D.5/6).
411    pub fn open(_key_id: impl Into<String>) -> Self {
412        unimplemented!(
413            "KeychainAttestor (Linux) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
414        );
415    }
416
417    /// `key_id` getter.
418    #[must_use]
419    pub fn key_id(&self) -> &str {
420        &self.key_id
421    }
422}
423
424#[cfg(target_os = "linux")]
425impl Attestor for KeychainAttestor {
426    fn sign(&self, _signing_input: &[u8]) -> Signature {
427        unimplemented!("KeychainAttestor::sign (Linux) — see T-3.D.5/6")
428    }
429    fn key_id(&self) -> &str {
430        &self.key_id
431    }
432    fn verifying_key(&self) -> VerifyingKey {
433        unimplemented!("KeychainAttestor::verifying_key (Linux) — see T-3.D.5/6")
434    }
435}
436
437/// Windows DPAPI backend skeleton. **Unimplemented in v0**.
438#[cfg(target_os = "windows")]
439#[derive(Debug)]
440pub struct KeychainAttestor {
441    key_id: String,
442}
443
444#[cfg(target_os = "windows")]
445impl KeychainAttestor {
446    /// Look up the cortex-identity key via Windows DPAPI.
447    /// **Unimplemented in v0**. TODO(T-3.D.5/6).
448    pub fn open(_key_id: impl Into<String>) -> Self {
449        unimplemented!(
450            "KeychainAttestor (Windows) not implemented in v0; use InMemoryAttestor (T-3.D.5/6)"
451        );
452    }
453
454    /// `key_id` getter.
455    #[must_use]
456    pub fn key_id(&self) -> &str {
457        &self.key_id
458    }
459}
460
461#[cfg(target_os = "windows")]
462impl Attestor for KeychainAttestor {
463    fn sign(&self, _signing_input: &[u8]) -> Signature {
464        unimplemented!("KeychainAttestor::sign (Windows) — see T-3.D.5/6")
465    }
466    fn key_id(&self) -> &str {
467        &self.key_id
468    }
469    fn verifying_key(&self) -> VerifyingKey {
470        unimplemented!("KeychainAttestor::verifying_key (Windows) — see T-3.D.5/6")
471    }
472}
473
474// -- serde adapter for [u8; 64] ------------------------------------------
475
476mod signature_serde {
477    use serde::{Deserialize, Deserializer, Serializer};
478
479    pub fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
480        s.serialize_bytes(bytes)
481    }
482
483    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
484        let v: Vec<u8> = Vec::deserialize(d)?;
485        if v.len() != 64 {
486            return Err(serde::de::Error::custom(format!(
487                "expected 64 signature bytes, got {}",
488                v.len()
489            )));
490        }
491        let mut out = [0u8; 64];
492        out.copy_from_slice(&v);
493        Ok(out)
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::canonical::{LineageBinding, SourceIdentity};
501    use chrono::TimeZone;
502    use ed25519_dalek::SigningKey;
503    use std::sync::atomic::{AtomicU8, Ordering};
504
505    /// Deterministic seed source so test failures are reproducible without
506    /// pulling `rand` into the runtime dep graph. Each call advances a
507    /// process-local counter so siblings get distinct keys within a run.
508    static SEED_COUNTER: AtomicU8 = AtomicU8::new(1);
509    fn fresh_attestor() -> InMemoryAttestor {
510        let n = SEED_COUNTER.fetch_add(1, Ordering::Relaxed);
511        let seed = [n; 32];
512        InMemoryAttestor::from_seed(&seed)
513    }
514
515    fn fixture_preimage(attestor: &InMemoryAttestor) -> AttestationPreimage {
516        AttestationPreimage {
517            schema_version: SCHEMA_VERSION_ATTESTATION,
518            source: SourceIdentity::User,
519            event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
520            payload_hash: "deadbeef".into(),
521            session_id: "session-001".into(),
522            ledger_id: "ledger-main".into(),
523            lineage: LineageBinding::PreviousHash("aaaaaaaaaaaaaaaa".into()),
524            signed_at: Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
525            key_id: attestor.key_id().to_string(),
526        }
527    }
528
529    /// Acceptance #2 (LANES): preimage with `schema_version: 999` →
530    /// `verify` returns `Err(VerifyError::UnknownSchemaVersion)`.
531    #[test]
532    fn unknown_schema_version_fails_closed() {
533        let attestor = fresh_attestor();
534        let mut p = fixture_preimage(&attestor);
535        let att = attest(&p, &attestor);
536
537        // Tamper schema version AFTER signing so the bytes we'd verify are
538        // a "future" preimage shape.
539        p.schema_version = 999;
540
541        let result = verify(&p, &att, &attestor.verifying_key(), attestor.key_id());
542        match result {
543            Err(VerifyError::UnknownSchemaVersion {
544                found: 999,
545                expected: SCHEMA_VERSION_ATTESTATION,
546            }) => {}
547            other => panic!("expected UnknownSchemaVersion, got {other:?}"),
548        }
549    }
550
551    /// Acceptance #3 (LANES): rule documented + verifier exercises it.
552    /// Two preimages with logically-equal fields produce identical
553    /// canonical bytes regardless of struct literal order, and a captured
554    /// signature verifies under either expression of the preimage.
555    #[test]
556    fn field_reorder_does_not_change_signed_semantics() {
557        let attestor = fresh_attestor();
558        let p1 = fixture_preimage(&attestor);
559        let att = attest(&p1, &attestor);
560
561        // Construct the "same" preimage with the field expressions in a
562        // different source order; the in-memory representation and
563        // canonical bytes must match.
564        let p2 = AttestationPreimage {
565            key_id: attestor.key_id().to_string(),
566            signed_at: chrono::Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap(),
567            lineage: LineageBinding::PreviousHash("aaaaaaaaaaaaaaaa".into()),
568            ledger_id: "ledger-main".into(),
569            session_id: "session-001".into(),
570            payload_hash: "deadbeef".into(),
571            event_id: "evt_01ARZ3NDEKTSV4RRFFQ69G5FAV".into(),
572            source: SourceIdentity::User,
573            schema_version: SCHEMA_VERSION_ATTESTATION,
574        };
575
576        verify(&p2, &att, &attestor.verifying_key(), attestor.key_id())
577            .expect("captured sig must verify under reordered preimage struct literal");
578    }
579
580    /// Acceptance #4 (LANES): tamper `previous_hash` → verify FAILS.
581    #[test]
582    fn wrong_prev_signature_fails() {
583        let attestor = fresh_attestor();
584        let p = fixture_preimage(&attestor);
585        let att = attest(&p, &attestor);
586
587        let mut tampered = p.clone();
588        tampered.lineage = LineageBinding::PreviousHash("bbbbbbbbbbbbbbbb".into());
589
590        let result = verify(
591            &tampered,
592            &att,
593            &attestor.verifying_key(),
594            attestor.key_id(),
595        );
596        assert_eq!(result, Err(VerifyError::BadSignature));
597    }
598
599    /// Acceptance #5 (LANES): replay a stale row's signature after the
600    /// chain has advanced → FAILS.
601    #[test]
602    fn replay_stale_row_after_chain_advance_fails() {
603        let attestor = fresh_attestor();
604
605        let mut p_old = fixture_preimage(&attestor);
606        p_old.lineage = LineageBinding::ChainPosition(10);
607        let att = attest(&p_old, &attestor);
608
609        // Verifier sees the same signature claimed under chain_position=20
610        // (i.e. the row was lifted forward in time after the chain moved).
611        let mut p_new = p_old.clone();
612        p_new.lineage = LineageBinding::ChainPosition(20);
613
614        let result = verify(&p_new, &att, &attestor.verifying_key(), attestor.key_id());
615        assert_eq!(result, Err(VerifyError::BadSignature));
616    }
617
618    /// Acceptance #6 positive (LANES): rotation envelope verifies cleanly
619    /// when signed by the holder of the old key.
620    #[test]
621    fn identity_rotate_accepted_when_envelope_verifies() {
622        let old = fresh_attestor();
623        let new = fresh_attestor();
624        let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
625
626        let env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
627
628        verify_rotation(&env).expect("envelope signed by old key must verify");
629    }
630
631    /// Acceptance #6 negative (LANES): tampered envelope (e.g. attacker
632    /// substitutes their own `new_pubkey`) → FAILS.
633    #[test]
634    fn identity_rotate_tampered_envelope_fails() {
635        let old = fresh_attestor();
636        let new = fresh_attestor();
637        let attacker_new = fresh_attestor();
638        let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
639
640        let mut env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
641
642        // Replace new_pubkey with the attacker's; signature no longer
643        // verifies because the attacker doesn't hold `old`'s signing key.
644        env.new_pubkey = attacker_new.verifying_key().to_bytes();
645
646        assert_eq!(verify_rotation(&env), Err(VerifyError::BadSignature));
647    }
648
649    /// Acceptance #6 schema-fail-closed: unknown rotation envelope schema
650    /// version → fails closed (no partial verify).
651    #[test]
652    fn rotation_envelope_unknown_schema_version_fails_closed() {
653        let old = fresh_attestor();
654        let new = fresh_attestor();
655        let signed_at = Utc.with_ymd_and_hms(2026, 5, 2, 12, 0, 0).unwrap();
656        let mut env = sign_rotation(&old.verifying_key(), &new.verifying_key(), signed_at, &old);
657        env.schema_version = 999;
658        match verify_rotation(&env) {
659            Err(VerifyError::UnknownSchemaVersion {
660                found: 999,
661                expected: SCHEMA_VERSION_ATTESTATION,
662            }) => {}
663            other => panic!("expected UnknownSchemaVersion, got {other:?}"),
664        }
665    }
666
667    /// Acceptance #7 (LANES): truncated / malformed preimage —
668    /// represented here as a verify call where the signature bytes have
669    /// been corrupted — returns Err with no partial success.
670    #[test]
671    fn malformed_payload_fails_closed() {
672        let attestor = fresh_attestor();
673        let p = fixture_preimage(&attestor);
674        let mut att = attest(&p, &attestor);
675        // Truncate-then-zero (signature is fixed 64 bytes; flipping bytes
676        // simulates a malformed payload reaching the verifier).
677        for byte in att.signature.iter_mut().take(8) {
678            *byte = 0;
679        }
680        let result = verify(&p, &att, &attestor.verifying_key(), attestor.key_id());
681        assert_eq!(result, Err(VerifyError::BadSignature));
682    }
683
684    /// Negative on key_id: the preimage's declared key_id does not match
685    /// what the verifier expects → fails closed.
686    #[test]
687    fn key_id_mismatch_fails_closed() {
688        let attestor = fresh_attestor();
689        let p = fixture_preimage(&attestor);
690        let att = attest(&p, &attestor);
691        let result = verify(&p, &att, &attestor.verifying_key(), "fp:wrong-key");
692        match result {
693            Err(VerifyError::KeyIdMismatch { .. }) => {}
694            other => panic!("expected KeyIdMismatch, got {other:?}"),
695        }
696    }
697
698    /// Positive baseline: a freshly-signed preimage verifies cleanly.
699    #[test]
700    fn fresh_attestation_verifies() {
701        let attestor = fresh_attestor();
702        let p = fixture_preimage(&attestor);
703        let att = attest(&p, &attestor);
704        verify(&p, &att, &attestor.verifying_key(), attestor.key_id())
705            .expect("freshly-signed attestation must verify");
706    }
707
708    /// Cross-source cannot replay: a signature for `User` does not verify
709    /// under a `Tool` preimage even when other fields are identical.
710    #[test]
711    fn cross_source_replay_fails() {
712        let attestor = fresh_attestor();
713        let p = fixture_preimage(&attestor);
714        let att = attest(&p, &attestor);
715
716        let mut p2 = p.clone();
717        p2.source = SourceIdentity::Tool {
718            name: "user".into(),
719        };
720        let result = verify(&p2, &att, &attestor.verifying_key(), attestor.key_id());
721        assert_eq!(result, Err(VerifyError::BadSignature));
722    }
723
724    /// Different signing key + same logical preimage → fails. Defends
725    /// against signature lift across attestor identities.
726    #[test]
727    fn different_key_does_not_verify() {
728        let a1 = fresh_attestor();
729        let p = fixture_preimage(&a1);
730        let att = attest(&p, &a1);
731
732        // Verify with a different key entirely (still expects a1's key_id
733        // because the preimage declares it; we override expected_key_id
734        // to a1's so we exercise the cryptographic check, not the key_id
735        // mismatch path).
736        let other = (2u8..=u8::MAX)
737            .map(|n| SigningKey::from_bytes(&[n; 32]).verifying_key())
738            .find(|candidate| candidate != &a1.verifying_key())
739            .expect("finite seed range must contain a distinct test key");
740        assert_eq!(
741            verify(&p, &att, &other, a1.key_id()),
742            Err(VerifyError::BadSignature)
743        );
744    }
745}