crtx-ledger 0.1.1

Append-only event log, hash chain, trace assembly, and audit records.
Documentation
//! On-disk wire shape for a JSONL row that participates in the Ed25519
//! signature chain (T-3.D.6, ADR 0010 §1-§2).
//!
//! A [`SignedRow`] is the canonical envelope written one-per-line to
//! `events.jsonl`. It flattens the existing [`cortex_core::Event`] fields
//! at the top level (so legacy tools that parse Event-shaped rows still
//! see every field they expect) and adds **one** new field, `signature`,
//! carrying the Ed25519 [`RowSignature`] over the row's canonical
//! attestation preimage.
//!
//! ## Why a wrapper instead of a new field on `Event`
//!
//! `Event` lives in `cortex-core` and is part of the BUILD_SPEC §9.1
//! wire-shape contract. Extending it would ripple through every consumer
//! crate. The wrapper keeps the change local to `cortex-ledger`: we own
//! the persistence layer, so we own the on-disk envelope.
//!
//! ## Backward compatibility on read
//!
//! `signature` is `Option<RowSignature>`; an old row that pre-dates this
//! lane (no signature field on disk) deserializes with `signature: None`.
//! That is **NOT** a silent pass — the audit verifier flags it as
//! [`crate::audit::FailureReason::MissingSignature`] (per ADR 0010 §1
//! "Single asymmetric trust domain": rows without a valid Ed25519
//! signature do not verify; there is no symmetric-MAC fallback). A
//! one-shot resign of any pre-3.D.6 fixture is required and documented
//! in `scripts/resign-jsonl.sh`.
//!
//! ## Wire shape (illustrative — exact bytes are normative in code)
//!
//! ```json
//! {
//!   "id": "evt_…",
//!   "schema_version": 1,
//!   "observed_at": "2026-05-03T12:00:00.000000Z",
//!   "recorded_at": "2026-05-03T12:00:00.100000Z",
//!   "source": { "type": "user" },
//!   "event_type": "cortex.event.user_message.v1",
//!   "trace_id": null,
//!   "session_id": "s-001",
//!   "domain_tags": [],
//!   "payload": { "text": "hello" },
//!   "payload_hash": "…",
//!   "prev_event_hash": null,
//!   "event_hash": "…",
//!   "signature": {
//!     "schema_version": 1,
//!     "key_id": "fp:abc…",
//!     "signed_at": "2026-05-03T12:00:00.000000Z",
//!     "bytes": "<base64 64-byte ed25519 signature>"
//!   }
//! }
//! ```

use chrono::{DateTime, Utc};
use cortex_core::Event;
use serde::{Deserialize, Serialize};

/// Per-row Ed25519 signature persisted alongside the [`Event`] fields.
///
/// `bytes` is base64-encoded (URL-safe, no padding) so JSONL rows remain
/// printable / grep-friendly. Verification reconstructs the canonical
/// attestation preimage and checks `bytes` against the active operator
/// public key (see [`crate::audit::verify_signed_chain`]).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RowSignature {
    /// Schema version of the attestation preimage encoder used to produce
    /// `bytes`. Mirrors
    /// [`cortex_core::canonical::SCHEMA_VERSION_ATTESTATION`]. Verifiers
    /// MUST fail closed on unknown versions (ADR 0010 §1b).
    pub schema_version: u16,
    /// Public-key fingerprint of the signing operator identity.
    pub key_id: String,
    /// Wall-clock timestamp at which the signature was produced. MUST
    /// equal the `signed_at` field that went into the canonical preimage.
    pub signed_at: DateTime<Utc>,
    /// Base64 (URL-safe, no padding) of the 64-byte Ed25519 signature.
    /// We avoid hex purely to keep the JSONL row narrower; a future
    /// schema bump can choose a different encoding.
    pub bytes: String,
}

/// On-disk envelope for one JSONL row.
///
/// `#[serde(flatten)]` on `event` keeps every existing top-level field
/// where legacy tooling expects it; `signature` is a single new optional
/// field. See module docs for the wire shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedRow {
    /// The semantic event payload (id, hashes, prev_event_hash, …). Its
    /// fields are flattened to the top level by serde so the on-disk row
    /// shape remains a superset of pre-3.D.6 rows.
    #[serde(flatten)]
    pub event: Event,
    /// Optional row signature. `None` only on rows written via the legacy
    /// unsigned `JsonlLog::append` path (kept for tests of unrelated
    /// crate features). A `None` signature **always** fails verify with
    /// [`crate::audit::FailureReason::MissingSignature`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<RowSignature>,
}

impl SignedRow {
    /// Build a row from an event with no signature attached. Used by the
    /// legacy unsigned append path; audit verify will flag the row.
    #[must_use]
    pub fn unsigned(event: Event) -> Self {
        Self {
            event,
            signature: None,
        }
    }
}

// ---------- base64 helpers (dependency-free, URL-safe, no padding) ----------

/// Encode bytes as URL-safe base64 (no padding). Local helper so the crate
/// does not pull in the `base64` crate for one signature field.
#[must_use]
pub fn b64_encode(bytes: &[u8]) -> String {
    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
    let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
    let mut i = 0;
    while i + 3 <= bytes.len() {
        let b0 = bytes[i];
        let b1 = bytes[i + 1];
        let b2 = bytes[i + 2];
        out.push(ALPHABET[(b0 >> 2) as usize] as char);
        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
        out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
        out.push(ALPHABET[(b2 & 0x3f) as usize] as char);
        i += 3;
    }
    let rem = bytes.len() - i;
    if rem == 1 {
        let b0 = bytes[i];
        out.push(ALPHABET[(b0 >> 2) as usize] as char);
        out.push(ALPHABET[((b0 & 0x03) << 4) as usize] as char);
    } else if rem == 2 {
        let b0 = bytes[i];
        let b1 = bytes[i + 1];
        out.push(ALPHABET[(b0 >> 2) as usize] as char);
        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
        out.push(ALPHABET[((b1 & 0x0f) << 2) as usize] as char);
    }
    out
}

/// Decode URL-safe base64 (no padding). Returns `None` on any invalid byte
/// or truncated final group; the verifier treats that as
/// [`crate::audit::FailureReason::BadSignature`].
#[must_use]
pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
    fn decode_char(c: u8) -> Option<u8> {
        match c {
            b'A'..=b'Z' => Some(c - b'A'),
            b'a'..=b'z' => Some(c - b'a' + 26),
            b'0'..=b'9' => Some(c - b'0' + 52),
            b'-' => Some(62),
            b'_' => Some(63),
            _ => None,
        }
    }
    let bytes = s.as_bytes();
    if bytes.len() % 4 == 1 {
        return None;
    }
    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
    let mut i = 0;
    while i + 4 <= bytes.len() {
        let v0 = decode_char(bytes[i])?;
        let v1 = decode_char(bytes[i + 1])?;
        let v2 = decode_char(bytes[i + 2])?;
        let v3 = decode_char(bytes[i + 3])?;
        out.push((v0 << 2) | (v1 >> 4));
        out.push((v1 << 4) | (v2 >> 2));
        out.push((v2 << 6) | v3);
        i += 4;
    }
    let rem = bytes.len() - i;
    if rem == 2 {
        let v0 = decode_char(bytes[i])?;
        let v1 = decode_char(bytes[i + 1])?;
        out.push((v0 << 2) | (v1 >> 4));
    } else if rem == 3 {
        let v0 = decode_char(bytes[i])?;
        let v1 = decode_char(bytes[i + 1])?;
        let v2 = decode_char(bytes[i + 2])?;
        out.push((v0 << 2) | (v1 >> 4));
        out.push((v1 << 4) | (v2 >> 2));
    }
    Some(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn b64_roundtrip_aligned() {
        let bytes: Vec<u8> = (0..64).collect();
        let s = b64_encode(&bytes);
        let back = b64_decode(&s).unwrap();
        assert_eq!(bytes, back);
    }

    #[test]
    fn b64_roundtrip_one_byte_remainder() {
        let bytes = vec![0xAB, 0xCD, 0xEF, 0x12];
        let s = b64_encode(&bytes);
        assert_eq!(s.len(), 6); // 4 bytes -> 6 base64 chars (no padding)
        assert_eq!(b64_decode(&s).unwrap(), bytes);
    }

    #[test]
    fn b64_roundtrip_two_byte_remainder() {
        let bytes = vec![0xAB, 0xCD, 0xEF, 0x12, 0x34];
        let s = b64_encode(&bytes);
        assert_eq!(s.len(), 7);
        assert_eq!(b64_decode(&s).unwrap(), bytes);
    }

    #[test]
    fn b64_rejects_invalid_chars() {
        assert!(b64_decode("AAAA!AAA").is_none());
    }

    #[test]
    fn b64_rejects_invalid_length() {
        // Length % 4 == 1 is impossible in canonical no-padding base64.
        assert!(b64_decode("A").is_none());
    }
}